whalibmob 5.10.5 → 5.10.7

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
@@ -1460,11 +1460,17 @@ client.on('account_restriction', (r) => {
1460
1460
  > was tried against a live server, which responded by dropping the stanza
1461
1461
  > entirely and answering nothing.
1462
1462
  >
1463
- > Some accounts get an answer with no restriction data in it at all — a bare
1464
- > `false` rather than a result. That throws, with the decoded value on
1465
- > `err.mexDecoded`, rather than being reported as "not restricted": an
1466
- > all-clear invented from an answer that never said so would have you send more
1467
- > messages and lengthen the very restriction you were asking about.
1463
+ > Some servers answer tersely — a bare `false` or `true` rather than the object
1464
+ > with the expiry in it. `false` is taken as "no restriction", because it is a
1465
+ > definite answer rather than a missing one. `true` sets `active` with
1466
+ > `expiryUnknown: true`, since it says there is a restriction but not when it
1467
+ > ends; the countdown then comes from the announcement.
1468
+ >
1469
+ > An answer that is neither — `null`, or anything that says nothing either way —
1470
+ > throws with the decoded value on `err.mexDecoded`, rather than being reported
1471
+ > as "not restricted". An all-clear invented from an answer that never gave one
1472
+ > would have you send more messages and lengthen the very restriction you were
1473
+ > asking about.
1468
1474
  >
1469
1475
  > The `account_restriction` event does not depend on any of this. The server
1470
1476
  > *announces* a restriction starting and lifting, and those announcements carry
@@ -2143,8 +2149,12 @@ Both methods accept a **Buffer** (use `fs.readFileSync` to load a file).
2143
2149
  ```js
2144
2150
  const fs = require('fs')
2145
2151
 
2146
- // change your own profile picture
2147
- await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
2152
+ // change your own profile picture — any image, any shape, any size
2153
+ // returns the new picture id, or 'remove' when it was taken down
2154
+ const picId = await client.changeProfilePicture(fs.readFileSync('./avatar.jpg'))
2155
+
2156
+ // pass null to remove it
2157
+ await client.changeProfilePicture(null)
2148
2158
 
2149
2159
  // change a group's picture (you must be admin)
2150
2160
  // returns the new picture id, or 'remove' when the picture was taken down
@@ -2155,8 +2165,25 @@ const picId = await client.changeGroupPicture('120363000000000000@g.us',
2155
2165
  await client.changeGroupPicture('120363000000000000@g.us', null)
2156
2166
  ```
2157
2167
 
2158
- `changeGroupPicture` throws when the server refuses — `406` for an image that is
2159
- not a JPEG it will take, `403` when you are not an admin of that group.
2168
+ `changeGroupPicture` throws when the server refuses — `406` for an image it will
2169
+ not take, `403` when you are not an admin of that group.
2170
+
2171
+ **Both calls re-encode the image before sending it.** WhatsApp wants a square
2172
+ 640×640 JPEG, and a picture that is not one is *dropped without an answer*
2173
+ rather than refused — so an unprepared file fails as a timeout that looks like a
2174
+ network fault. The image is cropped square, scaled and written out as a fresh
2175
+ JPEG, which also strips EXIF and any progressive encoding the file was carrying.
2176
+
2177
+ This needs `jimp` (an optional dependency) or `sharp`. Without either, the file
2178
+ is sent as it is and an already-correct picture still works.
2179
+
2180
+ ```js
2181
+ // skip the re-encode if you have prepared the image yourself
2182
+ await client.changeProfilePicture(buf, { raw: true })
2183
+
2184
+ // or choose the size
2185
+ await client.changeProfilePicture(buf, { size: 640, quality: 50 })
2186
+ ```
2160
2187
 
2161
2188
  ## Privacy
2162
2189
 
@@ -2199,6 +2226,32 @@ await client.changePrivacySetting('groups_add', 'contacts')
2199
2226
  await client.changePrivacySetting('call_add', 'known')
2200
2227
  ```
2201
2228
 
2229
+ **Each setting takes its own values**, and they are not interchangeable:
2230
+
2231
+ | setting | accepts |
2232
+ |---|---|
2233
+ | `last_seen`, `profile_picture`, `status`, `groups_add` | `all` · `contacts` · `contact_blacklist` · `none` |
2234
+ | `read_receipts` | `all` · `none` |
2235
+ | `online` | `all` · `match_last_seen` |
2236
+ | `call_add` | `all` · `known` |
2237
+ | `messages` | `all` · `contacts` |
2238
+ | `defense` | `on_standard` · `off` |
2239
+ | `stickers` | `contacts` · `contact_allowlist` · `none` |
2240
+
2241
+ Friendlier words are translated: `on`, `off`, `everyone`, `nobody`,
2242
+ `my_contacts`, `contacts_except`, `contact_whitelist`. `on` becomes `all` — or
2243
+ `on_standard` for `defense`, which spells its on-state differently.
2244
+
2245
+ ```js
2246
+ await client.changePrivacySetting('read_receipts', 'on') // sent as 'all'
2247
+ await client.changePrivacySetting('read_receipts', 'off') // sent as 'none'
2248
+ ```
2249
+
2250
+ A value the setting does not take throws before anything is sent. This matters
2251
+ more than it looks: the server does not refuse an unknown value, it drops the
2252
+ stanza and never answers, so the mistake would otherwise surface as a timeout
2253
+ that looks like a network problem.
2254
+
2202
2255
  ### Read Privacy Settings
2203
2256
 
2204
2257
  ```js
@@ -3286,20 +3339,6 @@ wa> /restriction --demo
3286
3339
  `--demo 90` uses ninety seconds instead of the default five hours, which is
3287
3340
  short enough to watch it reach zero and stop on its own.
3288
3341
 
3289
- Some accounts get a reply that decodes cleanly but holds no restriction data.
3290
- The command says that, rather than reading it as an all-clear:
3291
-
3292
- ```sh
3293
- wa> /restriction
3294
- checking account restriction...
3295
- error: mex query 23983697327930364: the server answered false rather than a result — this query returns no data for this account
3296
-
3297
- The reply was read successfully — it simply contains no restriction
3298
- data. This query returns nothing usable for your account.
3299
-
3300
- You will still be told about a restriction: the server announces one when
3301
- it starts and when it lifts, and those announcements carry the countdown.
3302
- ```
3303
3342
 
3304
3343
  An account that is fine says so and returns immediately:
3305
3344
 
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> change own profile picture (JPEG)
450
+ /photo <file>|remove change or remove own profile picture (JPEG)
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
@@ -1387,6 +1387,9 @@ async function handleLine(line) {
1387
1387
  out(' types: last_seen profile_picture status online read_receipts');
1388
1388
  out(' groups_add call_add messages defense stickers');
1389
1389
  out(' values: all contacts contact_blacklist contact_allowlist none');
1390
+ out(' not every setting takes every value — read_receipts is');
1391
+ out(' all/none, online is all/match_last_seen. on and off are');
1392
+ out(' accepted and translated.');
1390
1393
  out(' match_last_seen known on_standard off');
1391
1394
  break;
1392
1395
  }
@@ -1398,11 +1401,12 @@ async function handleLine(line) {
1398
1401
  case '/photo': {
1399
1402
  requireConn();
1400
1403
  const file = p[1];
1401
- if (!file) { fail('usage: /photo <file>'); break; }
1402
- if (!fs.existsSync(file)) { fail('file not found: ' + file); break; }
1403
- const buf = fs.readFileSync(file);
1404
- await _client.changeProfilePicture(buf);
1405
- out('profile picture updated');
1404
+ if (!file) { fail('usage: /photo <file> (or /photo remove)'); break; }
1405
+ if (file !== 'remove' && !fs.existsSync(file)) { fail('file not found: ' + file); break; }
1406
+ const buf = file === 'remove' ? null : fs.readFileSync(file);
1407
+ const picId = await _client.changeProfilePicture(buf);
1408
+ out(picId === 'remove' ? 'profile picture removed'
1409
+ : 'profile picture updated' + (picId ? ' id=' + picId : ''));
1406
1410
  break;
1407
1411
  }
1408
1412
 
@@ -1518,13 +1522,11 @@ async function handleLine(line) {
1518
1522
  fail(e.message);
1519
1523
  if (e.mexDecoded !== undefined) {
1520
1524
  out('');
1521
- out(' The reply was read successfully — it simply contains no restriction');
1522
- out(' data. This query returns nothing usable for your account.');
1525
+ out(' The reply was read, but it says nothing about a restriction either');
1526
+ out(' way — so it is not being reported as an all-clear.');
1523
1527
  out('');
1524
- out(' You will still be told about a restriction: the server announces one when');
1525
- out(' it starts and when it lifts, and those announcements carry the countdown.');
1526
- out(' Leave the session connected and watch for:');
1527
- out(' ACCOUNT RESTRICTED — <reason>, HH:MM:SS remaining');
1528
+ out(' You will still be told if one starts: the server announces that, and');
1529
+ out(' the announcement carries the countdown.');
1528
1530
  } else if (e.mexFormat) {
1529
1531
  out('');
1530
1532
  out(' The query worked, but the reply is in an encoding that could not be');
package/lib/Client.js CHANGED
@@ -10,6 +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
14
  const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
14
15
  const { BinaryNode } = require('./BinaryNode');
15
16
  const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
@@ -74,6 +75,46 @@ const PRIVACY_NAME_ALIASES = {
74
75
  stickers: 'stickers'
75
76
  };
76
77
 
78
+ // What each setting will actually accept. The server does not argue with a
79
+ // value it does not know — it drops the stanza and never answers, so a typo
80
+ // looks exactly like a network problem. Checking here turns that into a
81
+ // sentence naming the choices.
82
+ const PRIVACY_ALLOWED_VALUES = {
83
+ last: ['all', 'contacts', 'contact_blacklist', 'none'],
84
+ profile: ['all', 'contacts', 'contact_blacklist', 'none'],
85
+ status: ['all', 'contacts', 'contact_blacklist', 'none'],
86
+ groupadd: ['all', 'contacts', 'contact_blacklist', 'none'],
87
+ readreceipts: ['all', 'none'],
88
+ online: ['all', 'match_last_seen'],
89
+ calladd: ['all', 'known'],
90
+ messages: ['all', 'contacts'],
91
+ defense: ['on_standard', 'off'],
92
+ stickers: ['contacts', 'contact_allowlist', 'none']
93
+ };
94
+
95
+ // The words people reach for, mapped to the ones the protocol uses. "on" and
96
+ // "off" are the obvious things to type for read receipts and mean nothing on
97
+ // the wire, which is exactly the mistake worth absorbing.
98
+ const PRIVACY_VALUE_ALIASES = {
99
+ everyone: 'all',
100
+ my_contacts: 'contacts',
101
+ contacts_except: 'contact_blacklist',
102
+ contact_whitelist: 'contact_allowlist',
103
+ nobody: 'none',
104
+ off: 'none',
105
+ on: 'all',
106
+ same_as_last_seen: 'match_last_seen',
107
+ last_seen: 'match_last_seen',
108
+ standard: 'on_standard'
109
+ };
110
+
111
+ // Where a word means something different for one setting than for the rest.
112
+ // "on" is "all" everywhere except defense, whose on-state has its own name.
113
+ const PRIVACY_VALUE_ALIASES_BY_NAME = {
114
+ defense: { on: 'on_standard' },
115
+ online: { on: 'all', off: 'match_last_seen' }
116
+ };
117
+
77
118
  // Wire name → the key it is reported under in the settings object.
78
119
  const PRIVACY_WIRE_TO_KEY = {
79
120
  last: 'lastSeen',
@@ -4288,19 +4329,60 @@ class WhalibmobClient extends EventEmitter {
4288
4329
 
4289
4330
  // Change profile picture
4290
4331
  // buf: Buffer with JPEG image data
4291
- async changeProfilePicture(buf) {
4332
+ // Change your own profile picture.
4333
+ //
4334
+ // buf: a Buffer holding any image, or null to remove the current picture.
4335
+ // Returns the new picture id, or 'remove' when it was taken down.
4336
+ //
4337
+ // The image is re-encoded to the square 640x640 JPEG the server expects
4338
+ // before it goes out. Sending a file as it came off disk is what a person
4339
+ // naturally tries and what the server silently drops — pass { raw: true } to
4340
+ // skip that if you have already prepared it yourself.
4341
+ //
4342
+ // The picture is not addressed by sending the IQ *to* yourself: it goes to
4343
+ // the server with the account named in `target`, the same shape a group
4344
+ // picture uses. Addressed the other way the server files it nowhere and never
4345
+ // answers, which looked from the outside like the picture had been set — the
4346
+ // stanza went out, nothing came back, and nothing checked.
4347
+ async changeProfilePicture(buf, opts) {
4292
4348
  if (!this._store) throw new Error('Not connected');
4293
- const selfJid = `${this._store.phoneNumber}@s.whatsapp.net`;
4294
- const id = this._genMsgId();
4349
+ opts = opts || {};
4350
+ const selfJid = this._ownDeviceJid
4351
+ ? toNonAdJid(this._ownDeviceJid())
4352
+ : `${this._store.phoneNumber}@s.whatsapp.net`;
4353
+
4354
+ const picture = (buf && !opts.raw) ? await prepareProfilePicture(buf, opts) : buf;
4355
+
4295
4356
  const node = new BinaryNode('iq', {
4296
- id,
4297
- xmlns: 'w:profile:picture',
4298
- to: selfJid,
4299
- type: 'set'
4300
- }, [
4301
- new BinaryNode('picture', { type: 'image' }, buf)
4302
- ]);
4303
- return this._sendIq(node);
4357
+ id: this._genMsgId(),
4358
+ xmlns: 'w:profile:picture',
4359
+ to: 's.whatsapp.net',
4360
+ target: jidStrToObj(selfJid),
4361
+ type: 'set'
4362
+ }, picture ? [new BinaryNode('picture', { type: 'image' }, picture)] : null);
4363
+
4364
+ const resp = await this._sendIq(node);
4365
+ if (!resp) {
4366
+ throw new Error('changeProfilePicture: no reply from server (IQ timed out). ' +
4367
+ 'The server drops a picture it will not take rather than refusing it, so ' +
4368
+ 'this usually means the image was not in the form it wants' +
4369
+ (opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
4370
+ : '. Install jimp if it is not present, so the image can be ' +
4371
+ 're-encoded before sending') + '.');
4372
+ }
4373
+ if (resp.attrs && resp.attrs.type === 'error') {
4374
+ const errNode = findChild(resp, 'error');
4375
+ const code = errNode && errNode.attrs && errNode.attrs.code;
4376
+ throw new Error('changeProfilePicture: ' +
4377
+ (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
4378
+ : String(code) === '401' ? 'not authorised to change this picture'
4379
+ : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
4380
+ }
4381
+
4382
+ if (!buf) return 'remove';
4383
+ const picNode = findChild(resp, 'picture');
4384
+ return (picNode && picNode.attrs && picNode.attrs.id)
4385
+ ? String(picNode.attrs.id) : null;
4304
4386
  }
4305
4387
 
4306
4388
  // ─── Privacy settings ─────────────────────────────────────────────────────
@@ -4373,6 +4455,8 @@ class WhalibmobClient extends EventEmitter {
4373
4455
  Object.keys(PRIVACY_NAME_ALIASES).join(', '));
4374
4456
  }
4375
4457
 
4458
+ const wireValue = this._privacyWireValue(name, value);
4459
+
4376
4460
  // WhatsApp Web does not put <user> children on this IQ; the exclusion list
4377
4461
  // is maintained separately. Kept for callers that pass it,
4378
4462
  // and only emitted when they do.
@@ -4390,7 +4474,7 @@ class WhalibmobClient extends EventEmitter {
4390
4474
  type: 'set'
4391
4475
  }, [
4392
4476
  new BinaryNode('privacy', {}, [
4393
- new BinaryNode('category', { name, value: String(value) },
4477
+ new BinaryNode('category', { name, value: wireValue },
4394
4478
  children.length > 0 ? children : null)
4395
4479
  ])
4396
4480
  ]);
@@ -4401,7 +4485,7 @@ class WhalibmobClient extends EventEmitter {
4401
4485
  const errNode = findChild(resp, 'error');
4402
4486
  const code = errNode && errNode.attrs && errNode.attrs.code;
4403
4487
  const text = errNode && errNode.attrs && errNode.attrs.text;
4404
- throw new Error('changePrivacySetting: server rejected ' + name + '=' + value +
4488
+ throw new Error('changePrivacySetting: server rejected ' + name + '=' + wireValue +
4405
4489
  (code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
4406
4490
  }
4407
4491
 
@@ -4409,10 +4493,31 @@ class WhalibmobClient extends EventEmitter {
4409
4493
  // rather than making the caller re-query.
4410
4494
  const key = PRIVACY_WIRE_TO_KEY[name];
4411
4495
  this._privacySettings = Object.assign({}, this._privacySettings || {},
4412
- { [key]: String(value) });
4496
+ { [key]: wireValue });
4413
4497
  return this._privacySettings;
4414
4498
  }
4415
4499
 
4500
+ // Resolve what the caller typed to what this particular setting accepts.
4501
+ //
4502
+ // The allowed set differs per setting — read receipts are all-or-none, online
4503
+ // is all-or-match-last-seen — so "contacts" is right for one and meaningless
4504
+ // for another. Sending a meaningless one gets no reply at all.
4505
+ _privacyWireValue(name, value) {
4506
+ const allowed = PRIVACY_ALLOWED_VALUES[name];
4507
+ const raw = String(value == null ? '' : value).trim().toLowerCase();
4508
+ const perName = PRIVACY_VALUE_ALIASES_BY_NAME[name] || {};
4509
+ const mapped = allowed && allowed.includes(raw)
4510
+ ? raw
4511
+ : (perName[raw] || PRIVACY_VALUE_ALIASES[raw] || raw);
4512
+
4513
+ if (allowed && !allowed.includes(mapped)) {
4514
+ throw new Error('changePrivacySetting: "' + value + '" is not a value ' +
4515
+ name + ' accepts — use one of ' + allowed.join(', ') +
4516
+ '. The server ignores anything else without answering.');
4517
+ }
4518
+ return mapped;
4519
+ }
4520
+
4416
4521
  // ──────────────────────────────────────────────────────────────────────────
4417
4522
  // MESSAGING — editMessage, deleteMessage, sendStatus, forwardMessage
4418
4523
  // ──────────────────────────────────────────────────────────────────────────
@@ -4901,8 +5006,26 @@ class WhalibmobClient extends EventEmitter {
4901
5006
  async fetchReachoutTimelock() {
4902
5007
  const payload = await this._mexQuery(MEX_QUERY_REACHOUT_TIMELOCK, {},
4903
5008
  'xwa2_fetch_account_reachout_timelock');
5009
+
5010
+ // Some servers answer this one tersely: a bare boolean rather than the
5011
+ // object with the expiry in it. `false` is a definite "no restriction" and
5012
+ // is taken at face value; `true` says there is one but not when it ends, so
5013
+ // it is marked as an expiry we do not know and the announcement supplies
5014
+ // the countdown.
5015
+ if (typeof payload === 'boolean') {
5016
+ return this._setReachoutTimelock(
5017
+ ReachoutTimelock.parseTimelockPayload({ is_active: payload }), 'query');
5018
+ }
5019
+
5020
+ if (payload == null || typeof payload !== 'object') {
5021
+ const err = new Error('the server answered ' + JSON.stringify(payload) +
5022
+ ', which says nothing about a restriction either way');
5023
+ err.mexDecoded = payload;
5024
+ throw err;
5025
+ }
5026
+
4904
5027
  return this._setReachoutTimelock(
4905
- ReachoutTimelock.parseTimelockPayload(payload || {}), 'query');
5028
+ ReachoutTimelock.parseTimelockPayload(payload), 'query');
4906
5029
  }
4907
5030
 
4908
5031
  /**
@@ -5035,16 +5158,10 @@ class WhalibmobClient extends EventEmitter {
5035
5158
  return dataPath ? decoded[dataPath] : decoded;
5036
5159
  }
5037
5160
 
5038
- // The server answered something that is not a GraphQL result at all —
5039
- // a bare boolean, most often. There is no data in it to return, and
5040
- // saying so beats handing back a shape the caller will misread.
5041
- const err = new Error('mex query ' + queryId + ': the server answered ' +
5042
- JSON.stringify(decoded) + ' rather than a result — this query returns ' +
5043
- 'no data for this account');
5044
- err.mexFormat = format ? String(format) : null;
5045
- err.mexPayload = raw;
5046
- err.mexDecoded = decoded;
5047
- throw err;
5161
+ // Not a GraphQL envelope but still an answer — a bare boolean, most
5162
+ // often. Handed back as-is for the caller to interpret, since only the
5163
+ // caller knows what its own query means.
5164
+ return decoded;
5048
5165
  }
5049
5166
 
5050
5167
  if (format && String(format).toLowerCase() !== 'json') {
@@ -5822,7 +5939,12 @@ class WhalibmobClient extends EventEmitter {
5822
5939
  //
5823
5940
  // Removal is the IQ with no body at all — an empty <picture> node is a
5824
5941
  // malformed upload rather than a delete, and the server answers 406 for it.
5825
- async changeGroupPicture(groupJid, buf) {
5942
+ //
5943
+ // The image is re-encoded to a square 640x640 JPEG first, on the same terms
5944
+ // as a profile picture; { raw: true } skips that.
5945
+ async changeGroupPicture(groupJid, buf, opts) {
5946
+ opts = opts || {};
5947
+ const picture = (buf && !opts.raw) ? await prepareProfilePicture(buf, opts) : buf;
5826
5948
  const id = this._genMsgId();
5827
5949
  const node = new BinaryNode('iq', {
5828
5950
  id,
@@ -5830,9 +5952,13 @@ class WhalibmobClient extends EventEmitter {
5830
5952
  target: jidStrToObj(makeJid(groupJid)),
5831
5953
  to: 's.whatsapp.net',
5832
5954
  type: 'set'
5833
- }, buf ? [new BinaryNode('picture', { type: 'image' }, buf)] : null);
5955
+ }, picture ? [new BinaryNode('picture', { type: 'image' }, picture)] : null);
5834
5956
  const resp = await this._sendIq(node);
5835
- if (!resp) throw new Error('changeGroupPicture: no reply from server (IQ timed out)');
5957
+ if (!resp) {
5958
+ throw new Error('changeGroupPicture: no reply from server (IQ timed out). ' +
5959
+ 'The server drops a picture it will not take rather than refusing it, so ' +
5960
+ 'this usually means the image was not in the form it wants.');
5961
+ }
5836
5962
  if (resp.attrs && resp.attrs.type === 'error') {
5837
5963
  const errNode = findChild(resp, 'error');
5838
5964
  const code = errNode && errNode.attrs && errNode.attrs.code;
@@ -290,6 +290,70 @@ async function imageThumbnail(buf, opts) {
290
290
  return out;
291
291
  }
292
292
 
293
+ // ─── Profile pictures ────────────────────────────────────────────────────────
294
+
295
+ // What WhatsApp's own apps upload. The server will not take an arbitrary file:
296
+ // it wants a square JPEG at this size, and a picture that is not one is dropped
297
+ // without an answer rather than refused, so the failure gives no clue.
298
+ const AVATAR_SIZE = 640;
299
+ const AVATAR_QUALITY = 50;
300
+
301
+ /**
302
+ * Re-encode an image into the form a profile picture has to be in.
303
+ *
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.
308
+ *
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.
311
+ */
312
+ async function prepareProfilePicture(buf, opts) {
313
+ opts = opts || {};
314
+ if (!Buffer.isBuffer(buf) || !buf.length) return buf;
315
+
316
+ const size = opts.size || AVATAR_SIZE;
317
+ const quality = opts.quality || AVATAR_QUALITY;
318
+
319
+ try {
320
+ const lib = await loadImageLib();
321
+
322
+ if (lib && lib.kind === 'sharp') {
323
+ return await lib.sharp(buf)
324
+ .resize(size, size, { fit: 'cover' })
325
+ .jpeg({ quality })
326
+ .toBuffer();
327
+ }
328
+
329
+ if (lib && lib.kind === 'jimp1') {
330
+ const img = await lib.Jimp.read(buf);
331
+ const min = Math.min(img.width, img.height);
332
+ const sq = img.crop({ x: 0, y: 0, w: min, h: min });
333
+ const resizeOpts = { w: size, h: size };
334
+ if (lib.ResizeStrategy && lib.ResizeStrategy.BILINEAR) {
335
+ resizeOpts.mode = lib.ResizeStrategy.BILINEAR;
336
+ }
337
+ return await sq.resize(resizeOpts).getBuffer('image/jpeg', { quality });
338
+ }
339
+
340
+ if (lib && lib.kind === 'jimp0') {
341
+ const Jimp = lib.Jimp;
342
+ const img = await Jimp.read(buf);
343
+ const min = Math.min(img.bitmap.width, img.bitmap.height);
344
+ img.crop(0, 0, min, min).resize(size, size).quality(quality);
345
+ return await img.getBufferAsync(Jimp.MIME_JPEG);
346
+ }
347
+ } catch (err) {
348
+ _whaDbg('[DBG] AVATAR could not re-encode: ' + (err && err.message));
349
+ return buf;
350
+ }
351
+
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.');
354
+ return buf;
355
+ }
356
+
293
357
  // ─── ffmpeg ──────────────────────────────────────────────────────────────────
294
358
 
295
359
  function run(cmd, args) {
@@ -385,8 +449,11 @@ module.exports = {
385
449
  probeImageSize,
386
450
  extractExifThumbnail,
387
451
  imageThumbnail,
452
+ prepareProfilePicture,
388
453
  videoThumbnail,
389
454
  hasFfmpeg,
390
455
  THUMB_WIDTH,
391
- THUMB_QUALITY
456
+ THUMB_QUALITY,
457
+ AVATAR_SIZE,
458
+ AVATAR_QUALITY
392
459
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.10.5",
3
+ "version": "5.10.7",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",