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 +62 -23
- package/cli.js +14 -12
- package/lib/Client.js +154 -28
- package/lib/MediaThumbnail.js +68 -1
- package/package.json +1 -1
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
|
|
1464
|
-
> `false`
|
|
1465
|
-
>
|
|
1466
|
-
>
|
|
1467
|
-
>
|
|
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
|
-
|
|
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
|
|
2159
|
-
not
|
|
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
|
|
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
|
|
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
|
|
1522
|
-
out('
|
|
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
|
|
1525
|
-
out('
|
|
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
|
-
|
|
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
|
-
|
|
4294
|
-
const
|
|
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:
|
|
4298
|
-
to:
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
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:
|
|
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 + '=' +
|
|
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]:
|
|
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
|
|
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
|
-
//
|
|
5039
|
-
//
|
|
5040
|
-
//
|
|
5041
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
5955
|
+
}, picture ? [new BinaryNode('picture', { type: 'image' }, picture)] : null);
|
|
5834
5956
|
const resp = await this._sendIq(node);
|
|
5835
|
-
if (!resp)
|
|
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;
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -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
|
};
|