@oxidezap/baileyrs 0.0.35 → 0.1.1

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 (44) hide show
  1. package/README.md +147 -24
  2. package/lib/Compatibility/legacy-store/namespaces.d.ts +20 -0
  3. package/lib/Compatibility/legacy-store/namespaces.js +27 -0
  4. package/lib/Compatibility/newsletter-results.d.ts +15 -0
  5. package/lib/Compatibility/newsletter-results.js +38 -0
  6. package/lib/Compatibility/proto-runtime.js +30 -20
  7. package/lib/Compatibility/websocket-client.d.ts +23 -2
  8. package/lib/Compatibility/websocket-client.js +47 -18
  9. package/lib/Socket/bridge-client-owner.d.ts +89 -0
  10. package/lib/Socket/bridge-client-owner.js +135 -0
  11. package/lib/Socket/business.d.ts +29 -0
  12. package/lib/Socket/business.js +104 -0
  13. package/lib/Socket/chat-actions.d.ts +20 -11
  14. package/lib/Socket/chat-actions.js +171 -83
  15. package/lib/Socket/events.d.ts +31 -0
  16. package/lib/Socket/events.js +144 -41
  17. package/lib/Socket/index.d.ts +113 -16
  18. package/lib/Socket/index.js +468 -157
  19. package/lib/Socket/internals.d.ts +88 -0
  20. package/lib/Socket/internals.js +145 -0
  21. package/lib/Socket/messages.d.ts +1 -12
  22. package/lib/Socket/messages.js +3 -20
  23. package/lib/Socket/newsletter.d.ts +61 -6
  24. package/lib/Socket/newsletter.js +125 -7
  25. package/lib/Socket/privacy.d.ts +25 -0
  26. package/lib/Socket/privacy.js +54 -0
  27. package/lib/Socket/server-queries.d.ts +38 -0
  28. package/lib/Socket/server-queries.js +121 -0
  29. package/lib/Socket/terminal-close-reporter.d.ts +79 -0
  30. package/lib/Socket/terminal-close-reporter.js +108 -0
  31. package/lib/Socket/terminal-close.d.ts +39 -0
  32. package/lib/Socket/terminal-close.js +51 -0
  33. package/lib/Socket/types.d.ts +6 -0
  34. package/lib/Types/Product.d.ts +9 -0
  35. package/lib/Utils/event-buffer.js +31 -0
  36. package/lib/Utils/index.d.ts +1 -0
  37. package/lib/Utils/index.js +3 -0
  38. package/lib/Utils/link-preview.d.ts +60 -0
  39. package/lib/Utils/link-preview.js +357 -0
  40. package/lib/Utils/messages.d.ts +31 -7
  41. package/lib/Utils/messages.js +49 -17
  42. package/lib/Utils/wrap-legacy-store.d.ts +1 -0
  43. package/lib/Utils/wrap-legacy-store.js +1 -0
  44. package/package.json +4 -2
@@ -0,0 +1,357 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { Buffer } from 'node:buffer';
10
+ import { lookup } from 'node:dns/promises';
11
+ import { isIP } from 'node:net';
12
+ import { Readable } from 'node:stream';
13
+ import { Boom } from './boom.js';
14
+ import { extractImageThumb } from './messages-media.js';
15
+ const THUMBNAIL_WIDTH_PX = 192;
16
+ /** An explicit scheme names a link wherever it sits, so nothing is required before it. */
17
+ const SCHEMED_URL = /https?:\/\/[^\s<>]+/i;
18
+ /**
19
+ * A bare host has to earn it: something before it, and an alphabetic last
20
+ * label, which is what separates `example.com` from `version 1.22`.
21
+ */
22
+ const BARE_HOST = /(^|[\s([{'"<])((?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s<>]*)?)(?=[\s.,!?)\]}>]|$)/i;
23
+ /**
24
+ * The first link in a piece of text, or undefined when there is none. Exported
25
+ * under an underscore so the extraction can be tested without a network.
26
+ *
27
+ * Trailing prose punctuation is dropped: a link at the end of a sentence
28
+ * carries the full stop with it, and `https://example.com.` is not what the
29
+ * writer meant. A closing bracket goes the same way, which costs the rare url
30
+ * that genuinely ends in one and saves the common case of a link in
31
+ * parentheses.
32
+ */
33
+ export const _firstLink = (text) => {
34
+ const schemed = SCHEMED_URL.exec(text);
35
+ const bare = BARE_HOST.exec(text);
36
+ // Whichever starts earlier. A bare match begins at the character before the
37
+ // host, so it is that offset, not the match's, that compares.
38
+ const bareAt = bare ? bare.index + bare[1].length : Number.POSITIVE_INFINITY;
39
+ const found = (schemed && schemed.index <= bareAt ? schemed[0] : bare?.[2])?.replace(/[.,!?;:'")\]}]+$/, '');
40
+ if (!found)
41
+ return undefined;
42
+ // Case-insensitively, since `HTTPS://` is a scheme too and prefixing it
43
+ // again would build a URL that parses as nothing.
44
+ return /^https?:\/\//i.test(found) ? found : `https://${found}`;
45
+ };
46
+ /** Enough for any preview thumbnail, and small enough that a hostile one cannot exhaust memory. */
47
+ const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024;
48
+ /**
49
+ * The eight groups an IPv6 literal stands for, with `::` expanded and any
50
+ * trailing dotted quad folded in. Undefined when the text is not one, so an
51
+ * unrecognised form is refused rather than read as public.
52
+ */
53
+ const hextetsOf = (address) => {
54
+ const [head, tail, ...rest] = address.split('::');
55
+ if (rest.length)
56
+ return undefined;
57
+ const groupsOf = (part) => {
58
+ if (!part)
59
+ return [];
60
+ const groups = [];
61
+ for (const piece of part.split(':')) {
62
+ if (piece.includes('.')) {
63
+ if (isIP(piece) !== 4)
64
+ return undefined;
65
+ const [a, b, c, d] = piece.split('.').map(Number);
66
+ groups.push((a << 8) | b, (c << 8) | d);
67
+ }
68
+ else if (/^[0-9a-f]{1,4}$/.test(piece)) {
69
+ groups.push(Number.parseInt(piece, 16));
70
+ }
71
+ else {
72
+ return undefined;
73
+ }
74
+ }
75
+ return groups;
76
+ };
77
+ const left = groupsOf(head ?? '');
78
+ const right = tail === undefined ? [] : groupsOf(tail);
79
+ if (!left || !right)
80
+ return undefined;
81
+ if (tail === undefined)
82
+ return left.length === 8 ? left : undefined;
83
+ const gap = 8 - left.length - right.length;
84
+ return gap < 0 ? undefined : [...left, ...Array.from({ length: gap }).fill(0), ...right];
85
+ };
86
+ /**
87
+ * The v4 address an IPv4-mapped IPv6 literal stands for, in each spelling of
88
+ * it: `::ffff:127.0.0.1`, `::ffff:7f00:1` and `::ffff:0:7f00:1` are one
89
+ * address, and judging only one leaves the others a way through.
90
+ */
91
+ const dottedQuad = (high, low) => `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
92
+ const mappedIPv4 = (groups) => {
93
+ const zeroesUpTo = (count) => groups.slice(0, count).every(group => group === 0);
94
+ if (!((zeroesUpTo(5) && groups[5] === 0xffff) || (zeroesUpTo(4) && groups[4] === 0xffff && groups[5] === 0))) {
95
+ return undefined;
96
+ }
97
+ return dottedQuad(groups[6], groups[7]);
98
+ };
99
+ const isPrivateIPv4 = (address) => {
100
+ const [a, b, c] = address.split('.').map(Number);
101
+ // Anything unparseable is refused rather than allowed, so a form not
102
+ // recognised here cannot become a way through.
103
+ if (a === undefined || b === undefined || c === undefined)
104
+ return true;
105
+ if (!Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(c))
106
+ return true;
107
+ if (a === 0 || a === 10 || a === 127)
108
+ return true;
109
+ if (a === 169 && b === 254)
110
+ return true;
111
+ if (a === 172 && b >= 16 && b <= 31)
112
+ return true;
113
+ if (a === 192 && b === 168)
114
+ return true;
115
+ if (a === 100 && b >= 64 && b <= 127)
116
+ return true;
117
+ // Not private, but not globally routable either. An operator who routes one
118
+ // of these internally would otherwise have it reachable from a preview.
119
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
120
+ return true;
121
+ if (a === 198 && (b === 18 || b === 19))
122
+ return true;
123
+ if (a === 198 && b === 51 && c === 100)
124
+ return true;
125
+ if (a === 203 && b === 0 && c === 113)
126
+ return true;
127
+ return a >= 224;
128
+ };
129
+ /**
130
+ * Judged by value rather than by how the address is written. Both callers hand
131
+ * this canonical text today, and resting on that would make the guard wrong the
132
+ * moment one of them stops.
133
+ */
134
+ const isPrivateIPv6 = (groups) => {
135
+ const mapped = mappedIPv4(groups);
136
+ if (mapped)
137
+ return isPrivateIPv4(mapped);
138
+ // NAT64 carries a v4 address the network translates to on the way out, so
139
+ // the embedded address is what decides. Only the /96 embedding can be read
140
+ // off the literal; any other length in the prefix is refused instead.
141
+ if (groups[0] === 0x0064 && groups[1] === 0xff9b) {
142
+ return groups.slice(2, 6).every(group => group === 0) ? isPrivateIPv4(dottedQuad(groups[6], groups[7])) : true;
143
+ }
144
+ // All of `::/96`, which covers the unspecified address, loopback and the
145
+ // deprecated v4-compatible forms.
146
+ if (groups.slice(0, 6).every(group => group === 0))
147
+ return true;
148
+ const first = groups[0];
149
+ // fc00::/7 unique-local, fe80::/10 link-local, fec0::/10 site-local and
150
+ // ff00::/8 multicast, which the v4 side already refuses as 224/4.
151
+ if ((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80)
152
+ return true;
153
+ return (first & 0xffc0) === 0xfec0 || (first & 0xff00) === 0xff00;
154
+ };
155
+ /**
156
+ * Addresses no outbound request from a link preview may reach: loopback, the
157
+ * private ranges, link-local and the cloud metadata address that sits inside
158
+ * link-local and is the usual target, plus the IPv6 equivalents.
159
+ */
160
+ const isPrivateAddress = (address) => {
161
+ if (isIP(address) !== 6)
162
+ return isPrivateIPv4(address);
163
+ const groups = hextetsOf(address.toLowerCase());
164
+ return groups ? isPrivateIPv6(groups) : true;
165
+ };
166
+ /**
167
+ * A preview page is attacker-controlled, and the image URL it advertises is
168
+ * whatever it wants. Unchecked, that turns a link preview into a request the
169
+ * bot makes on the page's behalf, to an address the operator never chose.
170
+ *
171
+ * The host is resolved and judged before the request, because a public name
172
+ * pointing at a private address is the whole trick. Redirects are refused
173
+ * rather than followed, since each hop would need judging again and a
174
+ * thumbnail is not worth that.
175
+ *
176
+ * A name that resolves differently between this check and the connection is
177
+ * not closed off: doing that means pinning the address into the socket, which
178
+ * needs a custom agent this package does not carry.
179
+ */
180
+ const publicAddressOf = async (target) => {
181
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
182
+ throw new Boom(`link preview: refusing to fetch ${target.protocol}`, { statusCode: 400 });
183
+ }
184
+ const host = target.hostname.replace(/^\[|\]$/g, '');
185
+ const resolved = isIP(host) ? [host] : (await lookup(host, { all: true })).map(({ address }) => address);
186
+ const [first] = resolved;
187
+ if (!first) {
188
+ throw new Boom(`link preview: ${host} did not resolve`, { statusCode: 400 });
189
+ }
190
+ // Every address a name answers with, not just the first: several records
191
+ // mean the one judged here need not be the one connected to.
192
+ const refused = resolved.find(address => isPrivateAddress(address));
193
+ if (refused) {
194
+ throw new Boom(`link preview: refusing to fetch a private address (${refused})`, { statusCode: 400 });
195
+ }
196
+ return first;
197
+ };
198
+ const assertPublicDestination = async (target) => {
199
+ await publicAddressOf(target);
200
+ };
201
+ /**
202
+ * Fetched here rather than through `getHttpStream`, which forwards neither the
203
+ * timeout nor the proxy and validates no destination. The credentials in
204
+ * `headers` are for the page, so they are sent to the thumbnail only when it
205
+ * is the same origin; another host advertised by that page must not receive
206
+ * them.
207
+ */
208
+ /** Exported under an underscore so the destination guard can be driven directly. */
209
+ export const _getCompressedJpegThumbnail = async (url, pageUrl, { thumbnailWidth, fetchOpts }) => {
210
+ if (fetchOpts.proxyUrl) {
211
+ // Silently connecting direct would defeat the reason a proxy was set.
212
+ throw new Boom('link preview thumbnail: proxyUrl is not applied to thumbnail fetches, so the thumbnail is skipped', {
213
+ statusCode: 501
214
+ });
215
+ }
216
+ // One deadline for the whole operation, started before the lookup: a slow
217
+ // resolver is as good a stall as a slow server, and the timeout is
218
+ // documented as bounding the thumbnail, not just its connection.
219
+ const signal = AbortSignal.timeout(fetchOpts.timeout);
220
+ const target = new URL(url, pageUrl);
221
+ await Promise.race([
222
+ assertPublicDestination(target),
223
+ new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason)))
224
+ ]);
225
+ const sameOrigin = target.origin === new URL(pageUrl).origin;
226
+ const response = await fetch(target, {
227
+ method: 'GET',
228
+ redirect: 'error',
229
+ signal,
230
+ headers: sameOrigin ? fetchOpts.headers : undefined
231
+ });
232
+ if (!response.ok) {
233
+ throw new Boom(`link preview thumbnail: ${target} answered ${response.status}`, { statusCode: response.status });
234
+ }
235
+ const declared = Number(response.headers.get('content-length'));
236
+ if (Number.isFinite(declared) && declared > MAX_THUMBNAIL_BYTES) {
237
+ throw new Boom(`link preview thumbnail: ${declared} bytes is larger than the ${MAX_THUMBNAIL_BYTES} allowed`, {
238
+ statusCode: 413
239
+ });
240
+ }
241
+ // A declared length is a claim, so the body is counted as it arrives.
242
+ const chunks = [];
243
+ let read = 0;
244
+ for await (const chunk of response.body ? Readable.fromWeb(response.body) : []) {
245
+ const bytes = chunk;
246
+ read += bytes.length;
247
+ if (read > MAX_THUMBNAIL_BYTES) {
248
+ throw new Boom(`link preview thumbnail: body exceeded the ${MAX_THUMBNAIL_BYTES} bytes allowed`, {
249
+ statusCode: 413
250
+ });
251
+ }
252
+ chunks.push(bytes);
253
+ }
254
+ return await extractImageThumb(Buffer.concat(chunks), thumbnailWidth);
255
+ };
256
+ /**
257
+ * Reads the first URL out of a piece of text and fetches what a link preview
258
+ * needs. Nothing here is protocol: it is an HTTP fetch, an OpenGraph parse and
259
+ * a thumbnail, which is why it belongs in this layer rather than the engine.
260
+ *
261
+ * Resolves to undefined for the two cases that mean "no preview": text with no
262
+ * link in it, and a page with no title. Everything else throws, including a
263
+ * timeout, because a swallowed failure is indistinguishable from a page that
264
+ * genuinely had nothing, and a caller retrying the first would give up on the
265
+ * second.
266
+ *
267
+ * The metadata parse comes from `link-preview-js`, an optional peer dependency
268
+ * this package already declares and had no reader for, so nothing new is
269
+ * pulled in: a consumer who does not want link previews does not install it
270
+ * and never calls this.
271
+ */
272
+ export const getUrlInfo = async (text, opts = { thumbnailWidth: THUMBNAIL_WIDTH_PX, fetchOpts: { timeout: 3000 } }) => {
273
+ if (opts.fetchOpts.proxyUrl) {
274
+ // Refused rather than half-applied. The page fetch would resolve the
275
+ // host locally, which both fails where only the proxy can resolve and
276
+ // leaks the destination where the proxy was chosen for privacy, and the
277
+ // thumbnail would connect direct. Honouring it needs a proxy dispatcher,
278
+ // which is a dependency this package does not carry.
279
+ throw new Boom('getUrlInfo: proxyUrl is not supported, because neither the metadata fetch nor the thumbnail can be routed through a proxy here, and doing either directly would defeat the reason it was configured', { statusCode: 501 });
280
+ }
281
+ if (opts.uploadImage) {
282
+ throw new Boom('getUrlInfo: uploadImage is not supported, because an upload function here takes plaintext and returns the encrypted result, while this option is typed for one that takes an already-encrypted file. Leave it unset and the thumbnail is generated locally.', { statusCode: 501 });
283
+ }
284
+ // A designed branch rather than a swallowed parser error: text with no link
285
+ // in it has no preview, which is an answer, not a failure. Deciding it here
286
+ // means every error the parser does raise is a real one.
287
+ const previewLink = _firstLink(text);
288
+ if (!previewLink)
289
+ return undefined;
290
+ {
291
+ const { getLinkPreview } = (await import(__rewriteRelativeImportExtension('link-preview-js')));
292
+ // The whole call is raced, not each step inside it. The parser takes its
293
+ // timeout up front but runs the resolver hook before starting that timer,
294
+ // so bounding the lookup and the request separately still let them add
295
+ // up. Racing the call is the only bound the parser cannot walk past.
296
+ //
297
+ // The resolver shares the same expiry rather than getting a timer of its
298
+ // own, so a lookup that outlives the deadline fails the hop instead of
299
+ // letting the parser open a request nobody is waiting for. What it has
300
+ // already opened is left to the parser's own timeout, since it exposes
301
+ // no signal to cancel through.
302
+ const deadline = AbortSignal.timeout(opts.fetchOpts.timeout);
303
+ const expired = new Promise((_resolve, reject) => deadline.addEventListener('abort', () => reject(deadline.reason), { once: true }));
304
+ const info = await Promise.race([
305
+ getLinkPreview(previewLink, {
306
+ ...opts.fetchOpts,
307
+ // `manual`, not `follow`: the redirect handler below only runs on
308
+ // manual, so following automatically would send every hop unchecked.
309
+ followRedirects: 'manual',
310
+ // Same site only, so a preview cannot become an open redirect
311
+ // follower. One hop, because that is what the parser offers under
312
+ // manual redirects: it consults this once and fetches once more.
313
+ handleRedirects: (baseURL, forwardedURL) => {
314
+ const from = new URL(baseURL);
315
+ const to = new URL(forwardedURL);
316
+ // Scheme and port too, not the host alone: the follow-up request
317
+ // carries the caller's headers, so a hop to http:// on the same
318
+ // name would put their credentials on the wire in clear, and one
319
+ // to another port would hand them to a different service.
320
+ if (to.protocol !== from.protocol || to.port !== from.port)
321
+ return false;
322
+ return (to.hostname === from.hostname ||
323
+ to.hostname === `www.${from.hostname}` ||
324
+ `www.${to.hostname}` === from.hostname);
325
+ },
326
+ // Resolves the host so the address, not the name, is judged. The
327
+ // parser rejects loopback on what this returns, and the private
328
+ // ranges are rejected here. A redirect brings it back.
329
+ resolveDNSHost: async (target) => await Promise.race([publicAddressOf(new URL(target)), expired]),
330
+ headers: opts.fetchOpts?.headers
331
+ }),
332
+ expired
333
+ ]);
334
+ if (!info?.title)
335
+ return undefined;
336
+ const [image] = info.images ?? [];
337
+ const urlInfo = {
338
+ 'canonical-url': info.url,
339
+ 'matched-text': text,
340
+ title: info.title,
341
+ description: info.description,
342
+ originalThumbnailUrl: image
343
+ };
344
+ if (image) {
345
+ // A preview without its thumbnail is still a preview, so a thumbnail
346
+ // that fails to render is logged rather than losing the whole result.
347
+ try {
348
+ urlInfo.jpegThumbnail = Buffer.from((await _getCompressedJpegThumbnail(image, info.url, opts)).buffer);
349
+ }
350
+ catch (err) {
351
+ opts.logger?.debug({ err, url: previewLink }, 'error in generating thumbnail');
352
+ }
353
+ }
354
+ return urlInfo;
355
+ }
356
+ };
357
+ //# sourceMappingURL=link-preview.js.map
@@ -41,22 +41,46 @@ export declare const normalizeMessageContent: (content: WAMessageContent | null
41
41
  * Eg. extracts the inner message from a disappearing message/view once message
42
42
  */
43
43
  export declare const extractMessageContent: (content: WAMessageContent | undefined | null) => WAMessageContent | undefined;
44
+ /**
45
+ * Every field is optional, so upstream's `{ reuploadRequest, logger }` and this
46
+ * package's `{ waClient }` are both accepted.
47
+ *
48
+ * `reuploadRequest` and `logger` are declared for that compatibility and are
49
+ * not read: the engine handles the re-upload request, the CDN failover and the
50
+ * logging itself, so a caller's versions would have nothing to do.
51
+ */
44
52
  export type DownloadMediaMessageContext = {
45
- reuploadRequest: (msg: WAMessage) => Promise<WAMessage>;
46
- logger: ILogger;
47
- /** Bridge client for media download handles CDN failover, auth refresh,
48
- * HMAC-SHA256 verification, and AES-256-CBC decryption internally. */
49
- waClient: Pick<WasmWhatsAppClient, 'downloadMedia' | 'downloadMediaStream'>;
53
+ reuploadRequest?: (msg: WAMessage) => Promise<WAMessage>;
54
+ logger?: ILogger;
55
+ /** Bridge client for media download. Falls back to the registered one. */
56
+ waClient?: Pick<WasmWhatsAppClient, 'downloadMedia' | 'downloadMediaStream'>;
50
57
  };
51
58
  /**
52
59
  * Downloads the given message. Throws an error if it's not a media message.
53
60
  *
54
61
  * Uses the Rust bridge for download — provides CDN failover, automatic auth
55
62
  * refresh on 401/404, HMAC-SHA256 integrity verification, and AES-256-CBC
56
- * decryption. Requires `ctx.waClient` (the bridge client).
63
+ * decryption, so a bridge client has to reach it.
64
+ *
65
+ * `ctx` is optional, as upstream has it: without one the client registered by
66
+ * the most recent `makeWASocket` is used, the same fallback
67
+ * `downloadContentFromMessage` offers for standalone calls. A host juggling
68
+ * several sockets should pass `ctx` explicitly, because the registration points
69
+ * at whichever client was created last.
57
70
  */
58
- export declare const downloadMediaMessage: <Type extends 'buffer' | 'stream'>(message: WAMessage, type: Type, options: MediaDownloadOptions, ctx: DownloadMediaMessageContext) => Promise<Type extends "buffer" ? Buffer<ArrayBufferLike> : Readable>;
71
+ export declare const downloadMediaMessage: <Type extends 'buffer' | 'stream'>(message: WAMessage, type: Type, options: MediaDownloadOptions, ctx?: DownloadMediaMessageContext) => Promise<Type extends "buffer" ? Buffer<ArrayBufferLike> : Readable>;
59
72
  export declare const _registerActiveBridgeClient: (client: WasmWhatsAppClient, logger?: ILogger) => void;
73
+ /**
74
+ * Drop the module-level pointer when `sock.end()` frees the client it points
75
+ * at. Without this the global keeps a strong reference to a freed
76
+ * `WasmWhatsAppClient`, so the next standalone `downloadContentFromMessage()`
77
+ * call reaches into a null wasm pointer and fails with an opaque
78
+ * wasm-bindgen error instead of the actionable "no bridge client" Boom.
79
+ *
80
+ * Identity-checked on purpose: a multi-account host that ends socket A after
81
+ * creating socket B must not clear B's registration.
82
+ */
83
+ export declare const _unregisterActiveBridgeClient: (client: WasmWhatsAppClient) => void;
60
84
  /**
61
85
  * Upstream-Baileys-compatible standalone media download. Builds a synthetic
62
86
  * `WAMessage` from the supplied media subcontent (image/video/audio/etc fields)
@@ -505,10 +505,14 @@ export const generateWAMessageContent = async (message, options) => {
505
505
  };
506
506
  export const generateWAMessageFromContent = (jid, message, options) => {
507
507
  const innerMessage = normalizeMessageContent(message);
508
- const key = getContentType(innerMessage);
509
508
  const timestamp = unixTimestampSeconds(options.timestamp);
510
509
  const { quoted, userJid } = options;
510
+ // Only the quote and ephemeral branches read the content key, and a plain
511
+ // send takes neither, so resolve it on demand instead of on every send.
512
+ let key;
511
513
  if (quoted && !isJidNewsletter(jid)) {
514
+ const contentKey = getContentType(innerMessage);
515
+ key = contentKey;
512
516
  const participant = quoted.key.fromMe
513
517
  ? userJid // TODO: Add support for LIDs
514
518
  : quoted.participant || quoted.key.participant || quoted.key.remoteJid;
@@ -520,7 +524,7 @@ export const generateWAMessageFromContent = (jid, message, options) => {
520
524
  if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) {
521
525
  delete quotedContent.contextInfo;
522
526
  }
523
- const contextInfo = ('contextInfo' in innerMessage[key] && innerMessage[key]?.contextInfo) || {};
527
+ const contextInfo = ('contextInfo' in innerMessage[contentKey] && innerMessage[contentKey]?.contextInfo) || {};
524
528
  contextInfo.participant = jidNormalizedUser(participant);
525
529
  contextInfo.stanzaId = quoted.key.id;
526
530
  contextInfo.quotedMessage = quotedMsg;
@@ -529,26 +533,27 @@ export const generateWAMessageFromContent = (jid, message, options) => {
529
533
  if (jid !== quoted.key.remoteJid) {
530
534
  contextInfo.remoteJid = quoted.key.remoteJid;
531
535
  }
532
- if (contextInfo && innerMessage[key]) {
536
+ if (contextInfo && innerMessage[contentKey]) {
533
537
  /* @ts-ignore */
534
- innerMessage[key].contextInfo = contextInfo;
538
+ innerMessage[contentKey].contextInfo = contextInfo;
535
539
  }
536
540
  }
537
541
  if (
538
542
  // if we want to send a disappearing message
539
543
  !!options?.ephemeralExpiration &&
540
- // and it's not a protocol message -- delete, toggle disappear message
541
- key !== 'protocolMessage' &&
542
- // already not converted to disappearing message
543
- key !== 'ephemeralMessage' &&
544
544
  // newsletters don't support ephemeral messages
545
545
  !isJidNewsletter(jid)) {
546
- /* @ts-ignore */
547
- innerMessage[key].contextInfo = {
548
- ...innerMessage[key].contextInfo,
549
- expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL
550
- //ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
551
- };
546
+ const contentKey = key ?? getContentType(innerMessage);
547
+ // skip protocol messages -- delete, toggle disappear message -- and
548
+ // content already converted to a disappearing message
549
+ if (contentKey !== 'protocolMessage' && contentKey !== 'ephemeralMessage') {
550
+ /* @ts-ignore */
551
+ innerMessage[contentKey].contextInfo = {
552
+ ...innerMessage[contentKey].contextInfo,
553
+ expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL
554
+ //ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
555
+ };
556
+ }
552
557
  }
553
558
  // `create` copies into a fresh instance; content built by
554
559
  // `generateWAMessageContent` already is one, and the branches above mutate
@@ -664,9 +669,20 @@ export const extractMessageContent = (content) => {
664
669
  *
665
670
  * Uses the Rust bridge for download — provides CDN failover, automatic auth
666
671
  * refresh on 401/404, HMAC-SHA256 integrity verification, and AES-256-CBC
667
- * decryption. Requires `ctx.waClient` (the bridge client).
672
+ * decryption, so a bridge client has to reach it.
673
+ *
674
+ * `ctx` is optional, as upstream has it: without one the client registered by
675
+ * the most recent `makeWASocket` is used, the same fallback
676
+ * `downloadContentFromMessage` offers for standalone calls. A host juggling
677
+ * several sockets should pass `ctx` explicitly, because the registration points
678
+ * at whichever client was created last.
668
679
  */
669
680
  export const downloadMediaMessage = async (message, type, options, ctx) => {
681
+ const waClient = ctx?.waClient ?? activeBridgeClient;
682
+ if (!waClient) {
683
+ throw new Boom('downloadMediaMessage: no bridge client available, and the download, its CDN failover and its decryption all happen in the engine. Pass `{ waClient: sock.waClient }`, use `sock.downloadMedia(message, type, options)`, or call after `makeWASocket()` has initialized.', { statusCode: 500 });
684
+ }
685
+ const withClient = { ...ctx, waClient };
670
686
  return (await downloadMsg());
671
687
  async function downloadMsg() {
672
688
  const mContent = extractMessageContent(message.message);
@@ -698,11 +714,11 @@ export const downloadMediaMessage = async (message, type, options, ctx) => {
698
714
  toBridgeMediaType(mediaType)
699
715
  ];
700
716
  if (type === 'buffer') {
701
- const data = await ctx.waClient.downloadMedia(...args);
717
+ const data = await withClient.waClient.downloadMedia(...args);
702
718
  return Buffer.from(data);
703
719
  }
704
720
  // Stream mode: Web ReadableStream from Rust → Node.js Readable
705
- const webStream = ctx.waClient.downloadMediaStream(...args);
721
+ const webStream = withClient.waClient.downloadMediaStream(...args);
706
722
  return Readable.fromWeb(webStream);
707
723
  }
708
724
  };
@@ -720,6 +736,22 @@ export const _registerActiveBridgeClient = (client, logger) => {
720
736
  activeBridgeClient = client;
721
737
  activeBridgeLogger = logger;
722
738
  };
739
+ /**
740
+ * Drop the module-level pointer when `sock.end()` frees the client it points
741
+ * at. Without this the global keeps a strong reference to a freed
742
+ * `WasmWhatsAppClient`, so the next standalone `downloadContentFromMessage()`
743
+ * call reaches into a null wasm pointer and fails with an opaque
744
+ * wasm-bindgen error instead of the actionable "no bridge client" Boom.
745
+ *
746
+ * Identity-checked on purpose: a multi-account host that ends socket A after
747
+ * creating socket B must not clear B's registration.
748
+ */
749
+ export const _unregisterActiveBridgeClient = (client) => {
750
+ if (activeBridgeClient !== client)
751
+ return;
752
+ activeBridgeClient = undefined;
753
+ activeBridgeLogger = undefined;
754
+ };
723
755
  const noopLogger = {
724
756
  level: 'silent',
725
757
  child: () => noopLogger,
@@ -8,5 +8,6 @@
8
8
  */
9
9
  export { wrapLegacyStore } from '../Compatibility/legacy-store/adapter.js';
10
10
  export { useLegacyMultiFileAuthState } from '../Compatibility/legacy-store/multi-file.js';
11
+ export { BRIDGE_INTERNAL_KEY_PREFIX, BRIDGE_INTERNAL_KEY_TYPES, isBridgeInternalKeyType } from '../Compatibility/legacy-store/namespaces.js';
11
12
  export type { WrappedLegacyStore } from '../Compatibility/legacy-store/types.js';
12
13
  //# sourceMappingURL=wrap-legacy-store.d.ts.map
@@ -8,4 +8,5 @@
8
8
  */
9
9
  export { wrapLegacyStore } from '../Compatibility/legacy-store/adapter.js';
10
10
  export { useLegacyMultiFileAuthState } from '../Compatibility/legacy-store/multi-file.js';
11
+ export { BRIDGE_INTERNAL_KEY_PREFIX, BRIDGE_INTERNAL_KEY_TYPES, isBridgeInternalKeyType } from '../Compatibility/legacy-store/namespaces.js';
11
12
  //# sourceMappingURL=wrap-legacy-store.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxidezap/baileyrs",
3
3
  "type": "module",
4
- "version": "0.0.35",
4
+ "version": "0.1.1",
5
5
  "description": "A Rust-powered WhatsApp Web library for JavaScript, with a Baileys-compatible API",
6
6
  "keywords": [
7
7
  "whatsapp",
@@ -57,6 +57,7 @@
57
57
  "compat:audit:json": "npm run build --silent && node scripts/compatibility/audit.ts --format=json",
58
58
  "compat:audit:missing": "npm run build --silent && node scripts/compatibility/audit.ts --only-missing",
59
59
  "compat:audit:proto": "node scripts/compatibility/proto-runtime-audit.ts --details --strict",
60
+ "compat:audit:wire": "node scripts/compatibility/wire-fidelity-audit.ts --details --strict",
60
61
  "compat:audit:strict": "npm run build --silent && node scripts/compatibility/audit.ts --strict --only-missing",
61
62
  "compat:sync-waproto": "node scripts/compatibility/waproto-facade.ts --sync",
62
63
  "compat:check-waproto": "node scripts/compatibility/waproto-facade.ts --check",
@@ -75,7 +76,7 @@
75
76
  },
76
77
  "dependencies": {
77
78
  "@hapi/boom": "^9.1.4",
78
- "@oxidezap/whatsapp-rust-bridge": "0.6.4",
79
+ "@oxidezap/whatsapp-rust-bridge": "0.7.0",
79
80
  "long": "^5.3.2",
80
81
  "pino": "^10.3.1",
81
82
  "protobufjs": "^7.6.5"
@@ -85,6 +86,7 @@
85
86
  "@types/node": "^26.1.1",
86
87
  "baileys": "7.0.0-rc13",
87
88
  "jimp": "^1.6.0",
89
+ "link-preview-js": "^3.2.0",
88
90
  "music-metadata": "^11.14.0",
89
91
  "oxfmt": "^0.59.0",
90
92
  "oxlint": "1.74.0",