whalibmob 5.10.8 → 5.10.12

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.
package/README.md CHANGED
@@ -928,55 +928,65 @@ const {
928
928
 
929
929
  ### `makeCacheableSignalKeyStore`
930
930
 
931
- Wraps a `SignalStore` with an in-memory NodeCache layer (5-minute TTL). All `get` calls for `sessions`, `preKeys`, `signedPreKeys`, and `identities` are served from cache on subsequent accesses. Writes invalidate the cache automatically.
931
+ Wraps a `SignalStore` with an in-memory cache (5-minute TTL). Reads for sessions, pre-keys, signed pre-keys and identity keys are served from cache on subsequent accesses; `store*` calls write through to both, and `remove*` / `delete*` calls drop the entry.
932
+
933
+ A lookup that finds nothing is **not** cached. Absence is the state most likely to change from underneath — a session about to be built, a pre-key about to be uploaded — so a remembered miss is the one that would hurt.
932
934
 
933
935
  `useClones` is set to `false` so that `SessionRecord` objects — which carry internal state and methods — are returned by reference and never deep-cloned.
934
936
 
935
- The wrapper also forwards `transaction()` and `isInTransaction()` calls to the underlying store when present, making it safe to stack with `addTransactionCapability`.
937
+ Every method the underlying store has is forwarded, including `transaction()` and `isInTransaction()` when present, so the wrapper is a drop-in replacement and safe to stack with `addTransactionCapability`.
936
938
 
937
939
  ```js
938
- const { SignalStore } = require('whalibmob')
939
- const { makeCacheableSignalKeyStore } = require('whalibmob')
940
+ const { SignalStore, makeCacheableSignalKeyStore } = require('whalibmob')
940
941
 
941
- const store = new SignalStore(/* ... */)
942
+ const store = new SignalStore()
942
943
  const cached = makeCacheableSignalKeyStore(store)
943
944
 
944
945
  // reads hit cache after first access
945
- const session = await cached.getSession('919634847671@s.whatsapp.net:0')
946
+ const session = await cached.loadSession('919634847671.0')
946
947
  ```
947
948
 
949
+ Call `await cached.flushCache()` to drop everything — after a key rotation, or when another process may have written to the same session file.
950
+
948
951
  **When to use:** whenever your `SignalStore` is backed by a remote or disk-based store (database, Redis, file system) and you want to reduce repeated lookups for sessions that haven't changed between sends.
949
952
 
950
953
  ### `addTransactionCapability`
951
954
 
952
- Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory; they are flushed to the underlying store atomically when `commit()` is called at the end of the transaction.
955
+ Wraps a `SignalStore` with batched-write (transaction) semantics. During a transaction all writes are buffered in memory and flushed to the underlying store in one shot when the callback returns — there is no `commit()` to call. Reads check the buffer first, so a transaction sees its own writes. If the callback throws, nothing is written at all and the error reaches the caller.
953
956
 
954
- Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a per-key-type `Mutex` with reference-counting to serialize concurrent writers safely.
957
+ Uses `AsyncLocalStorage` to propagate transaction context across async call chains, and a `Mutex` with reference-counting per transaction key.
955
958
 
956
959
  ```js
957
- const { addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
960
+ const { SignalStore, addTransactionCapability, makeCacheableSignalKeyStore } = require('whalibmob')
958
961
 
959
962
  // recommended: cache first, then transactions on top
960
- const base = new SignalStore(/* ... */)
961
- const cached = makeCacheableSignalKeyStore(base)
962
- const txnStore = addTransactionCapability(cached)
963
+ const base = new SignalStore()
964
+ const cached = makeCacheableSignalKeyStore(base)
965
+ const txnStore = addTransactionCapability(cached)
963
966
 
964
967
  // inside a send flow
965
968
  await txnStore.transaction(async () => {
966
- // all writes are buffered
967
- await txnStore.setSession('919634847671@s.whatsapp.net:0', sessionRecord)
968
- await txnStore.setPreKey(1, preKeyPair)
969
- // commit is called automatically at the end of the transaction callback
970
- })
969
+ // all writes are buffered until this callback returns
970
+ await txnStore.storeSession('919634847671.0', sessionRecord)
971
+ await txnStore.storePreKey(1, preKeyPair)
972
+ }, 'send')
971
973
  ```
972
974
 
973
- Stacking order matters: put `makeCacheableSignalKeyStore` below `addTransactionCapability` so that the cache always sees the committed state.
975
+ The second argument is a scope: two transactions with different keys run concurrently, two with the same key run one after the other. It defaults to `'default'`. Any key is safe — the transaction locks are kept separate from the locks reads take, so no choice of key can make a transaction wait on itself.
976
+
977
+ Nested `transaction()` calls reuse the enclosing context instead of opening a second one, so an inner transaction does not commit on its own.
978
+
979
+ Stacking order matters: put `makeCacheableSignalKeyStore` below `addTransactionCapability`, so that a transaction which rolls back never reaches the cache. The other order works too — a failed transaction flushes the cache rather than leave it holding writes the store never took — but it throws away good entries to do it.
974
980
 
975
981
  **When to use:** for high-throughput servers that send to many recipients concurrently and need to batch Signal key writes into a single atomic flush per message.
976
982
 
977
983
  ### `assertMeId`
978
984
 
979
- Validates that a store object has a registered phone number and returns the canonical `@s.whatsapp.net` JID. Throws an `Error` if the store lacks a `phoneNumber` or has `registered !== true`.
985
+ Returns the account's JID, or throws an `Error` describing what is missing.
986
+
987
+ Works with both kinds of store — the SMS store from `initAuthCreds` / `createNewStore`, and the companion store from `createNewWebStore`. Only the wording of the error differs, since the way out of "not registered yet" is SMS verification in one case and pairing in the other.
988
+
989
+ Once registered, the JID the server assigned is preferred (`store.me.id`) — on a companion it carries the device suffix, and rebuilding it from the phone number drops that silently. Before registration it throws, including during the pairing window: requesting a pairing code writes a placeholder `me` with no suffix, and that is not treated as being linked.
980
990
 
981
991
  ```js
982
992
  const { assertMeId } = require('whalibmob')
@@ -985,7 +995,8 @@ const store = loadStore(sessFile)
985
995
 
986
996
  try {
987
997
  const jid = assertMeId(store)
988
- // jid === '919634847671@s.whatsapp.net'
998
+ // '919634847671:12@s.whatsapp.net' once connected,
999
+ // '919634847671@s.whatsapp.net' before that
989
1000
  console.log('account JID:', jid)
990
1001
  } catch (err) {
991
1002
  console.error('store is not registered:', err.message)
@@ -996,7 +1007,9 @@ try {
996
1007
 
997
1008
  ### `initAuthCreds`
998
1009
 
999
- Creates a fresh credential store for the given phone number. Functionally equivalent to `createNewStore` but also initialises the extra fields the library expects for account sync: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, and `advSecretKey`.
1010
+ Creates a fresh credential store for the given phone number — the key pairs, registration ID and device identifiers a number needs before it can even ask for an SMS code. This is what `/reg code` calls.
1011
+
1012
+ It returns everything `createNewStore` does, plus a few fields kept for application code that expects them: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, `processedHistoryMessages` and `advSecretKey`. Those extras live on the object only — `saveStore` does not write them, so they are not there again after a reload. Nothing in the library reads them; treat them as a convenience, not as state.
1000
1013
 
1001
1014
  ```js
1002
1015
  const { initAuthCreds, saveStore } = require('whalibmob')
@@ -1013,10 +1026,35 @@ const store = initAuthCreds(phone)
1013
1026
  saveStore(store, sessFile)
1014
1027
  ```
1015
1028
 
1016
- This is the function used internally by the CLI for all new session creation. Prefer it over `createNewStore` for forward compatibility.
1029
+ This is the function the CLI uses for every new SMS session. Prefer it over `createNewStore` for forward compatibility.
1017
1030
 
1018
1031
  > [!NOTE]
1019
- > `initAuthCreds` and `createNewStore` produce equivalent stores for all current library operations. The additional fields from `initAuthCreds` are there for future-proofing and interoperability.
1032
+ > `initAuthCreds` and `createNewStore` produce equivalent stores for every current library operation, and identical files on disk. Use `createNewWebStore` instead when the device will be linked as a companion rather than registered by SMS — that one adds the pairing fields, and its own serialiser persists them.
1033
+
1034
+ The file it writes has exactly these 16 keys:
1035
+
1036
+ ```json
1037
+ {
1038
+ "phoneNumber": "40712345678",
1039
+ "noiseKeyPair": { "private": "…", "public": "…" },
1040
+ "identityKeyPair": { "private": "…", "public": "…" },
1041
+ "signedPreKey": { "id": 3649616, "private": "…", "public": "…", "signature": "…" },
1042
+ "registrationId": 2183,
1043
+ "fdid": "2f701f8b-d693-4728-…",
1044
+ "deviceId": "CBIyyJRAokOdYoTYEjTbng==",
1045
+ "identityId": "SE8Q785oful7dGdBgNpwjg==",
1046
+ "advertisingId": "5c97eea9-783f-4fcc-…",
1047
+ "backupToken": "…",
1048
+ "registered": true,
1049
+ "codePending": false,
1050
+ "name": "Boss",
1051
+ "version": "2.26.9.75",
1052
+ "device": { "os": "ios", "platform": 1, "model": "iPhone 15 Pro", "…": "…" },
1053
+ "advIdentity": "…"
1054
+ }
1055
+ ```
1056
+
1057
+ `registered` and `codePending` are the two that move: both `false` when the store is created, `codePending` flips to `true` once a code has been requested, and `verifyCode` sets `registered` to `true` and `codePending` back to `false`. `advIdentity` stays `null` until the server sends the signed device identity in `<success>`.
1020
1058
 
1021
1059
  ### Recommended Stacking Pattern
1022
1060
 
@@ -1036,7 +1074,8 @@ const {
1036
1074
  let store = loadStore(sessFile) || initAuthCreds(phone)
1037
1075
 
1038
1076
  // 2. build the layered Signal key store
1039
- const signalStore = new SignalStore(store)
1077
+ const signalStore = new SignalStore()
1078
+ signalStore.attachFile(sessFile)
1040
1079
  const cachedStore = makeCacheableSignalKeyStore(signalStore)
1041
1080
  const txnStore = addTransactionCapability(cachedStore)
1042
1081
 
@@ -1791,9 +1830,11 @@ Photos and videos are sent with an inline preview so they show up in the chat
1791
1830
  directly, rather than as a placeholder the recipient has to tap. The dimensions
1792
1831
  are read out of the file header with no dependency at all. The preview itself
1793
1832
  uses `jimp` (installed automatically as an optional dependency) or `sharp` if
1794
- you have it; failing both, a photo that carries an EXIF thumbnail still gets
1795
- one. Video previews need `ffmpeg` on PATH — on Termux, `pkg install ffmpeg`.
1796
- Without any of these the media still sends, just without the preview.
1833
+ you have it; failing both, a photo that carries an EXIF thumbnail gets one for
1834
+ free, and anything else is scaled in process — JPEG, PNG, GIF and BMP all work
1835
+ with nothing installed. Video previews need `ffmpeg` on PATH — on Termux,
1836
+ `pkg install ffmpeg`. Without any of these the media still sends, just without
1837
+ the preview.
1797
1838
 
1798
1839
  ### Image Message
1799
1840
 
@@ -2180,8 +2221,25 @@ rather than refused — so an unprepared file fails as a timeout that looks like
2180
2221
  network fault. The image is cropped square, scaled and written out as a fresh
2181
2222
  JPEG, which also strips EXIF and any progressive encoding the file was carrying.
2182
2223
 
2183
- This needs `jimp` (an optional dependency) or `sharp`. Without either, the file
2184
- is sent as it is and an already-correct picture still works.
2224
+ **No image library is required.** JPEG, PNG, GIF and BMP are decoded, cropped
2225
+ and re-encoded in process, so a bare `npm install` on a phone or a container
2226
+ built without a compiler converts a picture just as well as a full desktop.
2227
+ Whatever is installed is preferred, and the fallbacks run in this order:
2228
+
2229
+ | | Handles | Needs |
2230
+ |---|---|---|
2231
+ | `sharp` or `jimp` | everything, best quality | either installed |
2232
+ | built in | JPEG, PNG, GIF, BMP | nothing |
2233
+ | `ffmpeg` | WebP, HEIC, progressive JPEG | `ffmpeg` on PATH |
2234
+ | metadata strip | an already-square JPEG | nothing |
2235
+
2236
+ The built-in converter also reads the EXIF orientation tag, so a photo taken in
2237
+ portrait is turned upright instead of arriving on its side, and flattens
2238
+ transparency onto white, since a JPEG has no alpha channel.
2239
+
2240
+ Only an exotic format with nothing installed — a WebP or a HEIC on a machine
2241
+ with no `ffmpeg` — still fails, and the error says so rather than reporting a
2242
+ bare `406`.
2185
2243
 
2186
2244
  ```js
2187
2245
  // skip the re-encode if you have prepared the image yourself
@@ -3133,11 +3191,16 @@ about updated
3133
3191
 
3134
3192
  #### CLI Change Profile Picture
3135
3193
 
3136
- Reads the image from disk and uploads it as your profile picture. Supported formats: JPEG, PNG.
3194
+ Reads the image from disk, crops it square, scales it to 640×640 and uploads it
3195
+ as your profile picture. JPEG, PNG, GIF and BMP need nothing installed; WebP and
3196
+ HEIC need `ffmpeg` or `jimp`.
3137
3197
 
3138
3198
  ```sh
3139
3199
  wa> /photo ./avatar.jpg
3140
- profile picture updated
3200
+ profile picture updated id=1753912045
3201
+
3202
+ wa> /photo remove
3203
+ profile picture removed
3141
3204
  ```
3142
3205
 
3143
3206
  #### CLI Change Privacy Settings
package/cli.js CHANGED
@@ -447,7 +447,7 @@ const HELP = `
447
447
  Profile
448
448
  /name <text> change display name
449
449
  /about <text> change own bio / about text
450
- /photo <file>|remove change or remove own profile picture (JPEG)
450
+ /photo <file>|remove change or remove own profile picture (any image)
451
451
  /privacy [<type> <value>] show or change privacy settings
452
452
  types: last_seen profile_picture status
453
453
  online read_receipts groups_add
package/lib/Client.js CHANGED
@@ -10,7 +10,7 @@ const { NoiseSocket } = require('./noise');
10
10
  const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
11
11
  const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
12
12
  const { getDeviceConfig } = require('./DeviceConfig');
13
- const { prepareProfilePicture } = require('./MediaThumbnail');
13
+ const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
14
14
  const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
15
15
  const { BinaryNode } = require('./BinaryNode');
16
16
  const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
@@ -252,6 +252,38 @@ function describeNodeBriefly(node, depth) {
252
252
  return out + '/>';
253
253
  }
254
254
 
255
+ // Say why a picture was turned down, from what can be read off the file.
256
+ //
257
+ // "not acceptable" is all the server offers, so the useful part is what we can
258
+ // work out ourselves: what the image is, whether it is square, and whether
259
+ // anything was available to fix it before it went out.
260
+ function _pictureRejected(what, picture, original, opts) {
261
+ const size = picture ? probeImageSize(picture) : null;
262
+ const bits = ['the server would not take this image (406 not-acceptable)'];
263
+
264
+ if (size && size.width && size.height) {
265
+ bits.push('it went out as ' + size.width + 'x' + size.height +
266
+ (size.width === size.height ? '' : ', which is not square'));
267
+ }
268
+ const isJpeg = picture && picture[0] === 0xff && picture[1] === 0xd8;
269
+ if (picture && !isJpeg) bits.push('and it is not a JPEG');
270
+
271
+ if (opts && opts.raw) {
272
+ bits.push('this call passed { raw: true }, so the file was sent exactly as given');
273
+ } else if (!isJpeg && original && !canDecodeImage(original)) {
274
+ // WebP, HEIC and the like need an outside converter; everything ordinary
275
+ // is handled without one.
276
+ bits.push('this format could not be converted here — install ffmpeg, or ' +
277
+ 'save the picture as a JPEG or PNG first');
278
+ } else {
279
+ bits.push('the picture was cropped square and re-encoded before sending, ' +
280
+ 'so the file itself is unlikely to be the problem — the account may not ' +
281
+ 'be allowed to set one');
282
+ }
283
+
284
+ return what + ': ' + bits.join('. ') + '.';
285
+ }
286
+
255
287
  function getNodeContent(node) {
256
288
  if (!node) return null;
257
289
  if (Buffer.isBuffer(node.content)) return node.content;
@@ -4365,15 +4397,16 @@ class WhalibmobClient extends EventEmitter {
4365
4397
  'The server drops a picture it will not take rather than refusing it, so ' +
4366
4398
  'this usually means the image was not in the form it wants' +
4367
4399
  (opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
4368
- : '. Install jimp if it is not present, so the image can be ' +
4369
- 're-encoded before sending') + '.');
4400
+ : '') + '.');
4370
4401
  }
4371
4402
  if (resp.attrs && resp.attrs.type === 'error') {
4372
4403
  const errNode = findChild(resp, 'error');
4373
4404
  const code = errNode && errNode.attrs && errNode.attrs.code;
4405
+ if (String(code) === '406') {
4406
+ throw new Error(_pictureRejected('changeProfilePicture', picture, buf, opts));
4407
+ }
4374
4408
  throw new Error('changeProfilePicture: ' +
4375
- (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
4376
- : String(code) === '401' ? 'not authorised to change this picture'
4409
+ (String(code) === '401' ? 'not authorised to change this picture'
4377
4410
  : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
4378
4411
  }
4379
4412
 
@@ -5960,9 +5993,11 @@ class WhalibmobClient extends EventEmitter {
5960
5993
  if (resp.attrs && resp.attrs.type === 'error') {
5961
5994
  const errNode = findChild(resp, 'error');
5962
5995
  const code = errNode && errNode.attrs && errNode.attrs.code;
5996
+ if (String(code) === '406') {
5997
+ throw new Error(_pictureRejected('changeGroupPicture', picture, buf, opts));
5998
+ }
5963
5999
  throw new Error('changeGroupPicture: ' +
5964
- (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
5965
- : String(code) === '403' ? 'you are not allowed to change this group picture'
6000
+ (String(code) === '403' ? 'you are not allowed to change this group picture'
5966
6001
  : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
5967
6002
  }
5968
6003
  if (!buf) return 'remove';
@@ -15,7 +15,8 @@
15
15
  // GIF and BMP — with no library at all.
16
16
  // • The thumbnail is made with sharp or jimp when either is installed. When
17
17
  // neither is, a JPEG that carries an EXIF thumbnail (which is to say, most
18
- // photos taken on a phone) still yields one, again with no library.
18
+ // photos taken on a phone) yields one for free, and anything else goes
19
+ // through the decoder in lib/image — still with nothing installed.
19
20
  // • Video frames need ffmpeg. Without it the video still sends, just without
20
21
  // an inline preview.
21
22
  //
@@ -30,6 +31,8 @@ const path = require('path');
30
31
  const crypto = require('crypto');
31
32
  const { execFile } = require('child_process');
32
33
 
34
+ const { toSquareJpeg, toScaledJpeg, canDecode: canDecodeImage } = require('./image');
35
+
33
36
  // The preview is deliberately tiny; it is only ever shown blurred behind the
34
37
  // real image while that downloads.
35
38
  const THUMB_WIDTH = 32;
@@ -220,8 +223,8 @@ async function loadImageLib() {
220
223
  }
221
224
  } catch (_) {}
222
225
 
223
- _whaDbg('[DBG] THUMB no image library — install jimp for inline previews ' +
224
- 'on images an EXIF thumbnail cannot cover');
226
+ _whaDbg('[DBG] THUMB no image library — falling back to the built-in ' +
227
+ 'decoder, which reads JPEG, PNG, GIF and BMP');
225
228
  return _imageLib;
226
229
  }
227
230
 
@@ -277,16 +280,32 @@ async function imageThumbnail(buf, opts) {
277
280
  _whaDbg('[DBG] THUMB image library failed: ' + (err && err.message));
278
281
  }
279
282
 
280
- // No library, or it could not read this file — fall back to whatever the
281
- // camera already embedded.
283
+ // No library, or it could not read this file. The camera's own embedded
284
+ // preview is the cheapest thing left, and on a phone photo it is usually
285
+ // there — a few kilobytes already sized about right.
282
286
  const exif = extractExifThumbnail(buf);
283
287
  if (exif) {
284
288
  out.thumbnail = exif;
285
289
  _whaDbg('[DBG] THUMB using embedded EXIF thumbnail (' + exif.length + ' bytes)');
286
- } else {
287
- _whaDbg('[DBG] THUMB none available — the image will show as a download ' +
288
- 'placeholder; install jimp to fix');
290
+ return out;
291
+ }
292
+
293
+ // Otherwise scale it here. Slower than a library, but a preview is 32 pixels
294
+ // wide and this is the difference between the picture showing in the chat and
295
+ // a grey download placeholder.
296
+ try {
297
+ const built = toScaledJpeg(buf, width, quality);
298
+ if (built && built.length) {
299
+ out.thumbnail = built;
300
+ _whaDbg('[DBG] THUMB made in-process (' + built.length + ' bytes)');
301
+ return out;
302
+ }
303
+ } catch (err) {
304
+ _whaDbg('[DBG] THUMB built-in scaler failed: ' + (err && err.message));
289
305
  }
306
+
307
+ _whaDbg('[DBG] THUMB none available — the image will show as a download ' +
308
+ 'placeholder; install jimp to fix');
290
309
  return out;
291
310
  }
292
311
 
@@ -301,13 +320,22 @@ const AVATAR_QUALITY = 50;
301
320
  /**
302
321
  * Re-encode an image into the form a profile picture has to be in.
303
322
  *
304
- * Cropped square from the top-left of the shorter side, scaled to 640x640, and
305
- * written out as a fresh JPEG. Re-encoding is the point rather than a side
306
- * effect: it strips EXIF, progressive scans and unusual chroma subsampling —
307
- * anything the file happened to carry — and produces a plain baseline JPEG.
323
+ * Cropped square, scaled to 640x640, and written out as a fresh JPEG.
324
+ * Re-encoding is the point rather than a side effect: it strips EXIF,
325
+ * progressive scans and unusual chroma subsampling — anything the file happened
326
+ * to carry — and produces a plain baseline JPEG.
308
327
  *
309
- * Returns the original buffer untouched when no image library is installed,
310
- * which at least gives an already-correct picture a chance to go through.
328
+ * Four converters are tried in turn, so a picture goes through whatever the
329
+ * machine happens to have:
330
+ *
331
+ * 1. sharp or jimp, when one is installed — the best quality and the widest
332
+ * range of formats.
333
+ * 2. the built-in converter, which reads JPEG, PNG, GIF and BMP with no
334
+ * dependency at all. This is the one that runs on a bare install.
335
+ * 3. ffmpeg, which covers what the built-in one does not: WebP, HEIC,
336
+ * progressive JPEG.
337
+ * 4. failing all of that, the file with its metadata segments stripped, which
338
+ * at least gives an already-square JPEG a chance.
311
339
  */
312
340
  async function prepareProfilePicture(buf, opts) {
313
341
  opts = opts || {};
@@ -345,15 +373,130 @@ async function prepareProfilePicture(buf, opts) {
345
373
  return await img.getBufferAsync(Jimp.MIME_JPEG);
346
374
  }
347
375
  } catch (err) {
348
- _whaDbg('[DBG] AVATAR could not re-encode: ' + (err && err.message));
349
- return buf;
376
+ _whaDbg('[DBG] AVATAR image library failed: ' + (err && err.message));
377
+ }
378
+
379
+ // No image library, or it could not read this file. The built-in converter
380
+ // needs nothing installed and handles the formats a picture is usually in.
381
+ try {
382
+ const built = toSquareJpeg(buf, size, quality);
383
+ if (built && built.length) {
384
+ _whaDbg('[DBG] AVATAR converted in-process to ' + size + 'x' + size +
385
+ ' (' + buf.length + ' → ' + built.length + ' bytes)');
386
+ return built;
387
+ }
388
+ } catch (err) {
389
+ _whaDbg('[DBG] AVATAR built-in converter failed: ' + (err && err.message));
390
+ }
391
+
392
+ // A format the built-in decoder does not read — WebP, HEIC, a progressive
393
+ // JPEG. ffmpeg can do the same job and is already used by the video path, so
394
+ // try it before giving up.
395
+ const viaFfmpeg = await avatarViaFfmpeg(buf, size, quality);
396
+ if (viaFfmpeg) return viaFfmpeg;
397
+
398
+ // Nothing that can re-encode. Strip the metadata segments at least: EXIF and
399
+ // the other APPn blocks are the part of an ordinary camera JPEG the server
400
+ // most often will not take, and removing them needs no decoder — the image
401
+ // data is left exactly as it was.
402
+ const stripped = stripJpegMetadata(buf);
403
+ if (stripped !== buf) {
404
+ _whaDbg('[DBG] AVATAR no converter — sent with metadata stripped (' +
405
+ buf.length + ' → ' + stripped.length + ' bytes). Install jimp or ffmpeg ' +
406
+ 'to have the image resized properly.');
407
+ return stripped;
350
408
  }
351
409
 
352
- _whaDbg('[DBG] AVATAR no image library — sending the file as it is. ' +
353
- 'WhatsApp wants a square JPEG; install jimp to have that done for you.');
410
+ _whaDbg('[DBG] AVATAR nothing could read this file — sending it as it is. ' +
411
+ 'WhatsApp wants a square JPEG; JPEG, PNG, GIF and BMP are converted here ' +
412
+ 'on their own, and ffmpeg or jimp covers the rest.');
354
413
  return buf;
355
414
  }
356
415
 
416
+ /**
417
+ * Crop square and resize with ffmpeg, which needs no native npm module.
418
+ *
419
+ * Returns null when ffmpeg is not installed or cannot read the file, so the
420
+ * caller can carry on to the next fallback.
421
+ */
422
+ async function avatarViaFfmpeg(buf, size, quality) {
423
+ if (!(await hasFfmpeg())) return null;
424
+
425
+ const src = tmpFile('.img');
426
+ const dst = tmpFile('.jpg');
427
+ try {
428
+ fs.writeFileSync(src, buf);
429
+ // Crop to the shorter side, scale, and write baseline JPEG. -q:v runs 2..31
430
+ // worst-to-best in ffmpeg, the opposite way round to a percentage quality.
431
+ const q = Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
432
+ const ok = await run('ffmpeg', [
433
+ '-y', '-i', src,
434
+ '-vf', "crop='min(iw,ih)':'min(iw,ih)',scale=" + size + ':' + size,
435
+ '-q:v', String(q),
436
+ '-f', 'mjpeg',
437
+ dst
438
+ ]);
439
+ if (ok === null) return null;
440
+ const out = fs.readFileSync(dst);
441
+ if (!out.length || out[0] !== 0xff || out[1] !== 0xd8) return null;
442
+ _whaDbg('[DBG] AVATAR re-encoded with ffmpeg (' + out.length + ' bytes)');
443
+ return out;
444
+ } catch (err) {
445
+ _whaDbg('[DBG] AVATAR ffmpeg failed: ' + (err && err.message));
446
+ return null;
447
+ } finally {
448
+ try { fs.unlinkSync(src); } catch (_) {}
449
+ try { fs.unlinkSync(dst); } catch (_) {}
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Drop every APPn segment from a JPEG, keeping the image itself byte for byte.
455
+ *
456
+ * EXIF, ICC profiles, thumbnails and camera maker notes all live in these, and
457
+ * a picture that carries them is the ordinary case a phone produces. Removing
458
+ * them takes no decoder — it is a walk over the segment headers — so it works
459
+ * where nothing else is installed.
460
+ *
461
+ * Returns the input unchanged when it is not a JPEG or has nothing to drop.
462
+ */
463
+ function stripJpegMetadata(buf) {
464
+ if (!Buffer.isBuffer(buf) || buf.length < 4) return buf;
465
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return buf; // not a JPEG
466
+
467
+ const keep = [buf.slice(0, 2)];
468
+ let i = 2, dropped = 0;
469
+
470
+ while (i + 4 <= buf.length) {
471
+ if (buf[i] !== 0xff) break; // out of step; leave the rest be
472
+ const marker = buf[i + 1];
473
+
474
+ // Start of scan — everything from here is entropy-coded image data.
475
+ if (marker === 0xda) { keep.push(buf.slice(i)); i = buf.length; break; }
476
+ // Standalone markers carry no length.
477
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
478
+ keep.push(buf.slice(i, i + 2)); i += 2; continue;
479
+ }
480
+
481
+ const len = buf.readUInt16BE(i + 2);
482
+ if (len < 2 || i + 2 + len > buf.length) break; // malformed; stop here
483
+ const seg = buf.slice(i, i + 2 + len);
484
+
485
+ // APP0 is the JFIF header, which is the one to keep; APP1..APPF and the
486
+ // comment segment are metadata.
487
+ const isAppN = marker >= 0xe1 && marker <= 0xef;
488
+ const isComment = marker === 0xfe;
489
+ if (isAppN || isComment) dropped += seg.length;
490
+ else keep.push(seg);
491
+
492
+ i += 2 + len;
493
+ }
494
+
495
+ if (!dropped) return buf;
496
+ if (i < buf.length) keep.push(buf.slice(i));
497
+ return Buffer.concat(keep);
498
+ }
499
+
357
500
  // ─── ffmpeg ──────────────────────────────────────────────────────────────────
358
501
 
359
502
  function run(cmd, args) {
@@ -450,6 +593,8 @@ module.exports = {
450
593
  extractExifThumbnail,
451
594
  imageThumbnail,
452
595
  prepareProfilePicture,
596
+ stripJpegMetadata,
597
+ canDecodeImage,
453
598
  videoThumbnail,
454
599
  hasFfmpeg,
455
600
  THUMB_WIDTH,