whalibmob 5.5.84 → 5.5.86

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
@@ -157,6 +157,7 @@ npm install -g whalibmob
157
157
  - [Quoted Reply](#quoted-reply)
158
158
  - [Location Message](#location-message)
159
159
  - [Contact Message (vCard)](#contact-message-vcard)
160
+ - [Call Link](#call-link)
160
161
  - [Media Messages](#media-messages)
161
162
  - [Image Message](#image-message)
162
163
  - [Video Message](#video-message)
@@ -1137,6 +1138,7 @@ const { id, encKey } = await client.sendPoll(
1137
1138
  1 // voters may pick 1 option (0 = unlimited)
1138
1139
  )
1139
1140
  // encKey (32-byte Buffer) is needed to decrypt incoming poll votes
1141
+ // (also returned as `messageSecret`, which is the name the protocol uses)
1140
1142
  ```
1141
1143
 
1142
1144
  ### Quoted Reply
@@ -1181,6 +1183,12 @@ await client.sendLocation('919634847671@s.whatsapp.net', 48.8566, 2.3522, {
1181
1183
  await client.sendLocation('120363000000000000@g.us', 51.5074, -0.1278, {
1182
1184
  name: 'London'
1183
1185
  })
1186
+
1187
+ // with a map image for the bubble — WhatsApp draws a blank card without one
1188
+ await client.sendLocation('919634847671@s.whatsapp.net', 48.8566, 2.3522, {
1189
+ name: 'Eiffel Tower',
1190
+ thumbnail: jpegBuffer
1191
+ })
1184
1192
  ```
1185
1193
 
1186
1194
  ### Contact Message (vCard)
@@ -1200,8 +1208,37 @@ const vcard = [
1200
1208
  await client.sendContact('919634847671@s.whatsapp.net', 'Alice Smith', vcard)
1201
1209
  ```
1202
1210
 
1211
+ ### Call Link
1212
+
1213
+ Ask WhatsApp for a link that lets someone join a call by tapping it, instead of
1214
+ being rung. Returns the token, the shareable link and the media type.
1215
+
1216
+ ```js
1217
+ const { link } = await client.createCallLink('video')
1218
+ // https://call.whatsapp.com/video/XXXXXXXX
1219
+
1220
+ // audio is the default
1221
+ const audio = await client.createCallLink()
1222
+
1223
+ // schedule it — startTime is seconds since the epoch
1224
+ await client.createCallLink('video', { startTime: 1800000000 })
1225
+
1226
+ // create it and send it in one step
1227
+ await client.sendCallLink('919634847671@s.whatsapp.net', 'video', {
1228
+ text: 'Join me here:'
1229
+ })
1230
+ ```
1231
+
1203
1232
  ## Media Messages
1204
1233
 
1234
+ Photos and videos are sent with an inline preview so they show up in the chat
1235
+ directly, rather than as a placeholder the recipient has to tap. The dimensions
1236
+ are read out of the file header with no dependency at all. The preview itself
1237
+ uses `jimp` (installed automatically as an optional dependency) or `sharp` if
1238
+ you have it; failing both, a photo that carries an EXIF thumbnail still gets
1239
+ one. Video previews need `ffmpeg` on PATH — on Termux, `pkg install ffmpeg`.
1240
+ Without any of these the media still sends, just without the preview.
1241
+
1205
1242
  ### Image Message
1206
1243
 
1207
1244
  ```js
package/lib/Client.js CHANGED
@@ -30,6 +30,10 @@ const {
30
30
  HistorySyncType
31
31
  } = require('./HistorySyncHandler');
32
32
 
33
+ // The two prefixes a call-link token is wrapped in, as WhatsApp Web uses them.
34
+ const CALL_AUDIO_PREFIX = 'https://call.whatsapp.com/voice/';
35
+ const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
36
+
33
37
  // whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
34
38
  const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
35
39
 
@@ -1392,6 +1396,20 @@ class WhalibmobClient extends EventEmitter {
1392
1396
 
1393
1397
  _handleCall(node) {
1394
1398
  const attrs = node.attrs || {};
1399
+
1400
+ // A <call> can also be the answer to something we asked for — creating a
1401
+ // call link, for one. Those come back on the same tag with the id we sent,
1402
+ // so they have to be handed to the waiting caller and not treated as an
1403
+ // incoming call to reject.
1404
+ if (attrs.id) {
1405
+ const handler = this._pendingIqs.get(attrs.id);
1406
+ if (handler) {
1407
+ this._pendingIqs.delete(attrs.id);
1408
+ handler(node);
1409
+ return;
1410
+ }
1411
+ }
1412
+
1395
1413
  this.emit('call', { from: attrs.from || '', node });
1396
1414
  // Reject call automatically
1397
1415
  if (this._socket && this._connected) {
@@ -3264,6 +3282,56 @@ class WhalibmobClient extends EventEmitter {
3264
3282
  return this._chatStates[key];
3265
3283
  }
3266
3284
 
3285
+ // ──────────────────────────────────────────────────────────────────────────
3286
+ // CALL LINKS
3287
+ // ──────────────────────────────────────────────────────────────────────────
3288
+
3289
+ // Ask the server for a call link that can be shared in a chat, so the other
3290
+ // side joins by tapping it instead of being rung.
3291
+ //
3292
+ // type 'audio' (default) or 'video'
3293
+ // opts { startTime } — seconds since the epoch, to schedule the call
3294
+ //
3295
+ // Returns { token, link, type }. The stanza is the one WhatsApp Web sends:
3296
+ // <call to="@call"><link_create media="..."/></call>, answered with the token
3297
+ // on a link_create of its own.
3298
+ async createCallLink(type, opts) {
3299
+ opts = opts || {};
3300
+ const media = type === 'video' ? 'video' : 'audio';
3301
+ const id = this._genMsgId();
3302
+ const children = opts.startTime
3303
+ ? [new BinaryNode('event', { start_time: String(opts.startTime) }, null)]
3304
+ : null;
3305
+
3306
+ const resp = await this._sendIq(new BinaryNode('call', { id, to: '@call' }, [
3307
+ new BinaryNode('link_create', { media }, children)
3308
+ ]));
3309
+
3310
+ if (!resp) throw new Error('createCallLink: no reply from server (timed out)');
3311
+ if (resp.attrs && resp.attrs.type === 'error') {
3312
+ const errNode = findChild(resp, 'error');
3313
+ const code = errNode && errNode.attrs && errNode.attrs.code;
3314
+ throw new Error('createCallLink: server rejected the request' +
3315
+ (code ? ' (' + code + ')' : ''));
3316
+ }
3317
+ const created = findChild(resp, 'link_create');
3318
+ const token = created && created.attrs && created.attrs.token;
3319
+ if (!token) throw new Error('createCallLink: server reply carried no token');
3320
+
3321
+ const prefix = media === 'video' ? CALL_VIDEO_PREFIX : CALL_AUDIO_PREFIX;
3322
+ return { token: String(token), link: prefix + token, type: media };
3323
+ }
3324
+
3325
+ // Create a call link and send it as a message in one step.
3326
+ // Returns the send result with the link attached.
3327
+ async sendCallLink(to, type, opts) {
3328
+ opts = opts || {};
3329
+ const { link } = await this.createCallLink(type, opts);
3330
+ const text = opts.text ? (opts.text + ' ' + link) : link;
3331
+ const res = await this.sendText(to, text, opts);
3332
+ return Object.assign({}, res, { link });
3333
+ }
3334
+
3267
3335
  // ──────────────────────────────────────────────────────────────────────────
3268
3336
  // GROUP OPERATIONS
3269
3337
  // ──────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,394 @@
1
+ 'use strict';
2
+
3
+ // ─── Media probing and inline thumbnails ─────────────────────────────────────
4
+ //
5
+ // WhatsApp renders a photo or video inline in the chat from the small JPEG
6
+ // carried in the message itself (ImageMessage.jpegThumbnail, field 16) together
7
+ // with the width and height. A message that arrives without them cannot be
8
+ // drawn, so the recipient gets the grey placeholder with a download arrow and
9
+ // has to tap it before seeing anything.
10
+ //
11
+ // Baileys builds that preview by resizing the source to 32 pixels wide at JPEG
12
+ // quality 50, and takes the original dimensions from the same decode. This does
13
+ // the same, with the difference that none of it is mandatory:
14
+ //
15
+ // • Dimensions are read straight out of the file header — JPEG, PNG, WebP,
16
+ // GIF and BMP — with no library at all.
17
+ // • The thumbnail is made with sharp or jimp when either is installed. When
18
+ // neither is, a JPEG that carries an EXIF thumbnail (which is to say, most
19
+ // photos taken on a phone) still yields one, again with no library.
20
+ // • Video frames need ffmpeg. Without it the video still sends, just without
21
+ // an inline preview.
22
+ //
23
+ // Nothing here throws: every entry point resolves to whatever it managed to
24
+ // work out, so a missing library degrades the preview and never the send.
25
+
26
+ const { dbg: _whaDbg } = require('./logger');
27
+
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+ const { execFile } = require('child_process');
33
+
34
+ // Baileys' numbers — the preview is deliberately tiny, it is only ever shown
35
+ // blurred behind the real image while that downloads.
36
+ const THUMB_WIDTH = 32;
37
+ const THUMB_QUALITY = 50;
38
+
39
+ // An EXIF thumbnail is usually ~160x120 and a few KB. Anything much larger is
40
+ // not a thumbnail worth embedding in every copy of an E2EE message.
41
+ const MAX_EXIF_THUMB = 12 * 1024;
42
+
43
+ const EXEC_TIMEOUT_MS = 20000;
44
+
45
+ // ─── Dimensions from the file header ─────────────────────────────────────────
46
+
47
+ function probeImageSize(buf) {
48
+ // Only enough to tell the formats apart; each branch below checks that the
49
+ // bytes it actually reads are there. A single large minimum would reject
50
+ // small-but-valid files.
51
+ if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
52
+
53
+ // PNG — 8-byte signature, then IHDR with width/height as big-endian uint32.
54
+ if (buf.readUInt32BE(0) === 0x89504e47 && buf.readUInt32BE(4) === 0x0d0a1a0a) {
55
+ if (buf.length < 24) return null;
56
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
57
+ }
58
+
59
+ // GIF — "GIF87a"/"GIF89a", then the logical screen descriptor, little-endian.
60
+ if (buf.slice(0, 3).toString('latin1') === 'GIF') {
61
+ if (buf.length < 10) return null;
62
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
63
+ }
64
+
65
+ // BMP
66
+ if (buf[0] === 0x42 && buf[1] === 0x4d && buf.length >= 26) {
67
+ return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) };
68
+ }
69
+
70
+ // WebP — RIFF container, three different chunk layouts.
71
+ if (buf.slice(0, 4).toString('latin1') === 'RIFF' &&
72
+ buf.slice(8, 12).toString('latin1') === 'WEBP') {
73
+ const chunk = buf.slice(12, 16).toString('latin1');
74
+ if (chunk === 'VP8 ' && buf.length >= 30) {
75
+ // Lossy: 14-bit dimensions in the keyframe header.
76
+ return {
77
+ width: buf.readUInt16LE(26) & 0x3fff,
78
+ height: buf.readUInt16LE(28) & 0x3fff
79
+ };
80
+ }
81
+ if (chunk === 'VP8L' && buf.length >= 25) {
82
+ // Lossless: 14 bits of (width-1) then 14 bits of (height-1), packed.
83
+ const b = buf.readUInt32LE(21);
84
+ return {
85
+ width: (b & 0x3fff) + 1,
86
+ height: ((b >> 14) & 0x3fff) + 1
87
+ };
88
+ }
89
+ if (chunk === 'VP8X' && buf.length >= 30) {
90
+ // Extended: 24-bit (width-1) and (height-1), little-endian.
91
+ const w = buf[24] | (buf[25] << 8) | (buf[26] << 16);
92
+ const h = buf[27] | (buf[28] << 8) | (buf[29] << 16);
93
+ return { width: w + 1, height: h + 1 };
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // JPEG — walk the marker chain to the start-of-frame, which holds the size.
99
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
100
+ let off = 2;
101
+ while (off + 8 < buf.length) {
102
+ if (buf[off] !== 0xff) { off++; continue; } // resync on padding
103
+ const marker = buf[off + 1];
104
+ if (marker === 0xd8 || marker === 0x01 ||
105
+ (marker >= 0xd0 && marker <= 0xd7)) { off += 2; continue; }
106
+ if (marker === 0xd9 || marker === 0xda) break; // end / scan data
107
+ const len = buf.readUInt16BE(off + 2);
108
+ if (len < 2) break;
109
+ // SOF0..SOF15, minus DHT (c4), JPG (c8) and DAC (cc) which share the range
110
+ const isSOF = marker >= 0xc0 && marker <= 0xcf &&
111
+ marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
112
+ if (isSOF) {
113
+ return { height: buf.readUInt16BE(off + 5), width: buf.readUInt16BE(off + 7) };
114
+ }
115
+ off += 2 + len;
116
+ }
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ // ─── EXIF thumbnail, straight out of a JPEG ──────────────────────────────────
123
+ //
124
+ // Phone cameras embed a small JPEG preview in the APP1/EXIF segment, in the
125
+ // second IFD. Tag 0x0201 is its offset from the start of the TIFF header and
126
+ // 0x0202 its length. Pulling that out gives a usable inline preview on a
127
+ // machine with no image library installed at all.
128
+
129
+ function extractExifThumbnail(buf) {
130
+ if (!Buffer.isBuffer(buf) || buf.length < 4) return null;
131
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
132
+
133
+ // Find the APP1 segment that starts with "Exif\0\0".
134
+ let off = 2, tiff = -1, app1End = -1;
135
+ while (off + 4 < buf.length) {
136
+ if (buf[off] !== 0xff) { off++; continue; }
137
+ const marker = buf[off + 1];
138
+ if (marker === 0xd9 || marker === 0xda) break;
139
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { off += 2; continue; }
140
+ const len = buf.readUInt16BE(off + 2);
141
+ if (len < 2) break;
142
+ if (marker === 0xe1 && off + 10 < buf.length &&
143
+ buf.slice(off + 4, off + 10).toString('latin1') === 'Exif\0\0') {
144
+ tiff = off + 10;
145
+ app1End = off + 2 + len;
146
+ break;
147
+ }
148
+ off += 2 + len;
149
+ }
150
+ if (tiff < 0 || tiff + 8 > buf.length) return null;
151
+
152
+ const bom = buf.slice(tiff, tiff + 2).toString('latin1');
153
+ if (bom !== 'II' && bom !== 'MM') return null;
154
+ const le = bom === 'II';
155
+ const u16 = (o) => le ? buf.readUInt16LE(o) : buf.readUInt16BE(o);
156
+ const u32 = (o) => le ? buf.readUInt32LE(o) : buf.readUInt32BE(o);
157
+
158
+ if (u16(tiff + 2) !== 42) return null;
159
+
160
+ // IFD0, then hop to IFD1 — the thumbnail lives in the second one.
161
+ const ifd0 = tiff + u32(tiff + 4);
162
+ if (ifd0 + 2 > buf.length) return null;
163
+ const count0 = u16(ifd0);
164
+ const nextPtr = ifd0 + 2 + count0 * 12;
165
+ if (nextPtr + 4 > buf.length) return null;
166
+ const ifd1Off = u32(nextPtr);
167
+ if (!ifd1Off) return null;
168
+ const ifd1 = tiff + ifd1Off;
169
+ if (ifd1 + 2 > buf.length) return null;
170
+
171
+ const count1 = u16(ifd1);
172
+ let thumbOff = 0, thumbLen = 0;
173
+ for (let i = 0; i < count1; i++) {
174
+ const e = ifd1 + 2 + i * 12;
175
+ if (e + 12 > buf.length) break;
176
+ const tag = u16(e);
177
+ if (tag === 0x0201) thumbOff = u32(e + 8);
178
+ else if (tag === 0x0202) thumbLen = u32(e + 8);
179
+ }
180
+ if (!thumbOff || !thumbLen || thumbLen > MAX_EXIF_THUMB) return null;
181
+
182
+ const start = tiff + thumbOff;
183
+ const end = start + thumbLen;
184
+ if (end > buf.length || (app1End > 0 && end > app1End)) return null;
185
+
186
+ const thumb = buf.slice(start, end);
187
+ // Must actually be a JPEG, or WhatsApp will render nothing.
188
+ if (thumb.length < 4 || thumb[0] !== 0xff || thumb[1] !== 0xd8) return null;
189
+ return Buffer.from(thumb);
190
+ }
191
+
192
+ // ─── Optional image library ──────────────────────────────────────────────────
193
+
194
+ let _imageLib; // undefined = not looked for yet, null = none available
195
+
196
+ async function loadImageLib() {
197
+ if (_imageLib !== undefined) return _imageLib;
198
+ _imageLib = null;
199
+
200
+ try {
201
+ const sharp = require('sharp');
202
+ _imageLib = { kind: 'sharp', sharp };
203
+ _whaDbg('[DBG] THUMB using sharp');
204
+ return _imageLib;
205
+ } catch (_) {}
206
+
207
+ // jimp 1.x is ESM-only, 0.x is CommonJS — accept either.
208
+ try {
209
+ const mod = await import('jimp');
210
+ const jimp = mod.default && mod.default.Jimp ? mod.default : mod;
211
+ if (jimp && jimp.Jimp) {
212
+ _imageLib = { kind: 'jimp1', Jimp: jimp.Jimp, ResizeStrategy: jimp.ResizeStrategy };
213
+ _whaDbg('[DBG] THUMB using jimp (v1)');
214
+ return _imageLib;
215
+ }
216
+ const legacy = mod.default || mod;
217
+ if (legacy && typeof legacy.read === 'function') {
218
+ _imageLib = { kind: 'jimp0', Jimp: legacy };
219
+ _whaDbg('[DBG] THUMB using jimp (v0)');
220
+ return _imageLib;
221
+ }
222
+ } catch (_) {}
223
+
224
+ _whaDbg('[DBG] THUMB no image library — install jimp for inline previews ' +
225
+ 'on images an EXIF thumbnail cannot cover');
226
+ return _imageLib;
227
+ }
228
+
229
+ // ─── Image: dimensions + inline preview ──────────────────────────────────────
230
+ //
231
+ // Returns { width, height, thumbnail } with whatever could be worked out; any
232
+ // field may be missing. Never rejects.
233
+
234
+ async function imageThumbnail(buf, opts) {
235
+ opts = opts || {};
236
+ const out = {};
237
+ if (!Buffer.isBuffer(buf) || buf.length === 0) return out;
238
+
239
+ const size = probeImageSize(buf);
240
+ if (size && size.width > 0 && size.height > 0) {
241
+ out.width = size.width;
242
+ out.height = size.height;
243
+ }
244
+
245
+ const width = opts.width || THUMB_WIDTH;
246
+ const quality = opts.quality || THUMB_QUALITY;
247
+
248
+ try {
249
+ const lib = await loadImageLib();
250
+ if (lib && lib.kind === 'sharp') {
251
+ const img = lib.sharp(buf);
252
+ const meta = await img.metadata();
253
+ if (meta && meta.width && meta.height) {
254
+ out.width = meta.width; out.height = meta.height;
255
+ }
256
+ out.thumbnail = await lib.sharp(buf).resize(width).jpeg({ quality }).toBuffer();
257
+ return out;
258
+ }
259
+ if (lib && lib.kind === 'jimp1') {
260
+ const img = await lib.Jimp.read(buf);
261
+ if (img.width && img.height) { out.width = img.width; out.height = img.height; }
262
+ const resizeOpts = { w: width };
263
+ if (lib.ResizeStrategy && lib.ResizeStrategy.BILINEAR) {
264
+ resizeOpts.mode = lib.ResizeStrategy.BILINEAR;
265
+ }
266
+ out.thumbnail = await img.resize(resizeOpts).getBuffer('image/jpeg', { quality });
267
+ return out;
268
+ }
269
+ if (lib && lib.kind === 'jimp0') {
270
+ const Jimp = lib.Jimp;
271
+ const img = await Jimp.read(buf);
272
+ if (img.bitmap) { out.width = img.bitmap.width; out.height = img.bitmap.height; }
273
+ img.resize(width, Jimp.AUTO).quality(quality);
274
+ out.thumbnail = await img.getBufferAsync(Jimp.MIME_JPEG);
275
+ return out;
276
+ }
277
+ } catch (err) {
278
+ _whaDbg('[DBG] THUMB image library failed: ' + (err && err.message));
279
+ }
280
+
281
+ // No library, or it could not read this file — fall back to whatever the
282
+ // camera already embedded.
283
+ const exif = extractExifThumbnail(buf);
284
+ if (exif) {
285
+ out.thumbnail = exif;
286
+ _whaDbg('[DBG] THUMB using embedded EXIF thumbnail (' + exif.length + ' bytes)');
287
+ } else {
288
+ _whaDbg('[DBG] THUMB none available — the image will show as a download ' +
289
+ 'placeholder; install jimp to fix');
290
+ }
291
+ return out;
292
+ }
293
+
294
+ // ─── ffmpeg ──────────────────────────────────────────────────────────────────
295
+
296
+ function run(cmd, args) {
297
+ return new Promise((resolve) => {
298
+ execFile(cmd, args, { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 },
299
+ (err, stdout) => resolve(err ? null : String(stdout)));
300
+ });
301
+ }
302
+
303
+ let _ffmpegChecked;
304
+ async function hasFfmpeg() {
305
+ if (_ffmpegChecked !== undefined) return _ffmpegChecked;
306
+ _ffmpegChecked = (await run('ffmpeg', ['-version'])) !== null;
307
+ if (!_ffmpegChecked) {
308
+ _whaDbg('[DBG] THUMB ffmpeg not found — videos will send without an inline ' +
309
+ 'preview (Termux: pkg install ffmpeg)');
310
+ }
311
+ return _ffmpegChecked;
312
+ }
313
+
314
+ function tmpFile(ext) {
315
+ return path.join(os.tmpdir(),
316
+ 'whalibmob-' + crypto.randomBytes(8).toString('hex') + ext);
317
+ }
318
+
319
+ // ─── Video: dimensions, duration, first-frame preview ────────────────────────
320
+ //
321
+ // input may be a file path or a Buffer; a Buffer is spilled to a temp file
322
+ // because ffmpeg needs to seek. Returns { width, height, seconds, thumbnail }
323
+ // with whatever could be worked out. Never rejects.
324
+
325
+ async function videoThumbnail(input, opts) {
326
+ opts = opts || {};
327
+ const out = {};
328
+ if (!(await hasFfmpeg())) return out;
329
+
330
+ let src = null, spilled = null;
331
+ try {
332
+ if (Buffer.isBuffer(input)) {
333
+ spilled = tmpFile('.mp4');
334
+ fs.writeFileSync(spilled, input);
335
+ src = spilled;
336
+ } else if (typeof input === 'string' && fs.existsSync(input)) {
337
+ src = input;
338
+ }
339
+ if (!src) return out;
340
+
341
+ // ffprobe ships with ffmpeg; when it is missing the frame grab below still
342
+ // works, the message just goes out without dimensions.
343
+ const probe = await run('ffprobe', [
344
+ '-v', 'error',
345
+ '-select_streams', 'v:0',
346
+ '-show_entries', 'stream=width,height:format=duration',
347
+ '-of', 'json', src
348
+ ]);
349
+ if (probe) {
350
+ try {
351
+ const j = JSON.parse(probe);
352
+ const st = j.streams && j.streams[0];
353
+ if (st && st.width && st.height) {
354
+ out.width = Number(st.width); out.height = Number(st.height);
355
+ }
356
+ const dur = j.format && parseFloat(j.format.duration);
357
+ if (Number.isFinite(dur) && dur > 0) out.seconds = Math.round(dur);
358
+ } catch (_) {}
359
+ }
360
+
361
+ // Same frame grab Baileys runs: first frame, scaled to the preview width
362
+ // with the aspect ratio kept.
363
+ const dest = tmpFile('.jpg');
364
+ try {
365
+ const ok = await run('ffmpeg', [
366
+ '-ss', '00:00:00', '-i', src, '-y',
367
+ '-vf', 'scale=' + (opts.width || THUMB_WIDTH) + ':-1',
368
+ '-vframes', '1', '-f', 'image2', dest
369
+ ]);
370
+ if (ok !== null && fs.existsSync(dest)) {
371
+ const thumb = fs.readFileSync(dest);
372
+ if (thumb.length > 0) out.thumbnail = thumb;
373
+ }
374
+ } finally {
375
+ try { fs.unlinkSync(dest); } catch (_) {}
376
+ }
377
+ } catch (err) {
378
+ _whaDbg('[DBG] THUMB video failed: ' + (err && err.message));
379
+ } finally {
380
+ if (spilled) { try { fs.unlinkSync(spilled); } catch (_) {} }
381
+ }
382
+
383
+ return out;
384
+ }
385
+
386
+ module.exports = {
387
+ probeImageSize,
388
+ extractExifThumbnail,
389
+ imageThumbnail,
390
+ videoThumbnail,
391
+ hasFfmpeg,
392
+ THUMB_WIDTH,
393
+ THUMB_QUALITY
394
+ };
@@ -18,9 +18,11 @@ const {
18
18
  encodeDeviceSentMessage, encodeProtocolMessage,
19
19
  encodeExtendedText,
20
20
  encodeSenderKeyDistributionMessage,
21
+ encodeMessageContextInfo,
21
22
  PROTOCOL_MSG_REVOKE, PROTOCOL_MSG_EPHEMERAL
22
23
  } = require('../proto/MessageProto');
23
24
  const { uploadMedia } = require('../MediaService');
25
+ const { imageThumbnail, videoThumbnail, probeImageSize } = require('../MediaThumbnail');
24
26
  const { tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken } = require('./TcTokenStore');
25
27
 
26
28
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -255,20 +257,32 @@ class MessageSender {
255
257
  const msgId = options.id || generateMessageId();
256
258
  const toJid = makeJid(to);
257
259
  const cdn = options._cdnForward;
258
- const upload = cdn ? cdn : await this._uploadMedia('image', imageData);
260
+ // The inline preview and the dimensions come from the same pass over the
261
+ // source bytes. Without them WhatsApp has nothing to draw and shows the
262
+ // grey download placeholder instead of the photo.
263
+ let upload, meta = {};
264
+ if (cdn) {
265
+ upload = cdn;
266
+ } else {
267
+ const buf = this._toMediaBuffer(imageData);
268
+ [upload, meta] = await Promise.all([
269
+ this._uploadMedia('image', buf),
270
+ imageThumbnail(buf).catch(() => ({}))
271
+ ]);
272
+ }
259
273
  const imgBuf = encodeMessage('image', encodeImageMessage({
260
274
  url: upload.url,
261
275
  mimetype: options.mimetype || upload.mimetype || guessMime(imageData, 'image/jpeg'),
262
276
  caption: options.caption || upload.caption || '',
263
277
  fileSha256: upload.fileSha256,
264
278
  fileLength: upload.fileLength || 0,
265
- height: options.height || upload.height || 0,
266
- width: options.width || upload.width || 0,
279
+ height: options.height || upload.height || meta.height || 0,
280
+ width: options.width || upload.width || meta.width || 0,
267
281
  mediaKey: upload.mediaKey,
268
282
  fileEncSha256: upload.fileEncSha256,
269
283
  directPath: upload.directPath,
270
284
  mediaKeyTimestamp: upload.mediaKeyTimestamp || Math.floor(Date.now() / 1000),
271
- jpegThumbnail: options.thumbnail || null,
285
+ jpegThumbnail: options.thumbnail || upload.jpegThumbnail || meta.thumbnail || null,
272
286
  contextInfo: options.contextInfo
273
287
  }));
274
288
  return this._sendMessage(toJid, msgId, imgBuf, 'media', { ...options, _mediaSubtype: 'image' });
@@ -281,25 +295,41 @@ class MessageSender {
281
295
  const msgId = options.id || generateMessageId();
282
296
  const toJid = makeJid(to);
283
297
  const cdn = options._cdnForward;
284
- const upload = cdn ? cdn : await this._uploadMedia('video', videoData);
298
+ // Same as an image, except the preview frame has to be cut by ffmpeg. When
299
+ // ffmpeg is not installed the video still sends — it just arrives without
300
+ // an inline preview.
301
+ let upload, meta = {};
302
+ if (cdn) {
303
+ upload = cdn;
304
+ } else {
305
+ const buf = this._toMediaBuffer(videoData);
306
+ [upload, meta] = await Promise.all([
307
+ this._uploadMedia('video', buf),
308
+ // ffmpeg needs to seek, so hand it the path when we have one rather
309
+ // than making it re-read a copy of the bytes.
310
+ videoThumbnail(typeof videoData === 'string' ? videoData : buf).catch(() => ({}))
311
+ ]);
312
+ }
285
313
  const vidBuf = encodeMessage('video', encodeVideoMessage({
286
314
  url: upload.url,
287
315
  mimetype: options.mimetype || upload.mimetype || guessMime(videoData, 'video/mp4'),
288
316
  fileSha256: upload.fileSha256,
289
317
  fileLength: upload.fileLength || 0,
290
- seconds: options.seconds || upload.seconds || 0,
318
+ seconds: options.seconds || upload.seconds || meta.seconds || 0,
291
319
  mediaKey: upload.mediaKey,
292
320
  caption: options.caption || upload.caption || '',
293
- height: options.height || upload.height || 0,
294
- width: options.width || upload.width || 0,
321
+ height: options.height || upload.height || meta.height || 0,
322
+ width: options.width || upload.width || meta.width || 0,
295
323
  fileEncSha256: upload.fileEncSha256,
296
324
  directPath: upload.directPath,
297
325
  mediaKeyTimestamp: upload.mediaKeyTimestamp || Math.floor(Date.now() / 1000),
298
- jpegThumbnail: options.thumbnail || null,
326
+ jpegThumbnail: options.thumbnail || upload.jpegThumbnail || meta.thumbnail || null,
299
327
  gifPlayback: options.gifPlayback || false,
300
328
  contextInfo: options.contextInfo
301
329
  }));
302
- return this._sendMessage(toJid, msgId, vidBuf, 'media', { ...options, _mediaSubtype: 'video' });
330
+ // whatsmeow labels a video with gifPlayback as mediatype "gif", not "video".
331
+ return this._sendMessage(toJid, msgId, vidBuf, 'media',
332
+ { ...options, _mediaSubtype: options.gifPlayback ? 'gif' : 'video' });
303
333
  }
304
334
 
305
335
  // ─── Audio / PTT ──────────────────────────────────────────────────────────
@@ -346,7 +376,7 @@ class MessageSender {
346
376
  fileEncSha256: upload.fileEncSha256,
347
377
  directPath: upload.directPath,
348
378
  mediaKeyTimestamp: upload.mediaKeyTimestamp || Math.floor(Date.now() / 1000),
349
- jpegThumbnail: options.thumbnail || null,
379
+ jpegThumbnail: options.thumbnail || upload.jpegThumbnail || null,
350
380
  contextInfo: options.contextInfo
351
381
  }));
352
382
  return this._sendMessage(toJid, msgId, docBuf, 'media', { ...options, _mediaSubtype: 'document' });
@@ -360,14 +390,20 @@ class MessageSender {
360
390
  const toJid = makeJid(to);
361
391
  const cdn = options._cdnForward;
362
392
  const upload = cdn ? cdn : await this._uploadMedia('sticker', stickerData);
393
+ // A sticker that is not actually 512x512 was being described as if it were,
394
+ // which stretches it in the bubble. The WebP header says what it really is.
395
+ let stkSize = null;
396
+ if (!cdn && (!options.width || !options.height)) {
397
+ try { stkSize = probeImageSize(this._toMediaBuffer(stickerData)); } catch (_) {}
398
+ }
363
399
  const stkBuf = encodeMessage('sticker', encodeStickerMessage({
364
400
  url: upload.url,
365
401
  fileSha256: upload.fileSha256,
366
402
  fileEncSha256: upload.fileEncSha256,
367
403
  mediaKey: upload.mediaKey,
368
404
  mimetype: options.mimetype || upload.mimetype || 'image/webp',
369
- height: options.height || upload.height || 512,
370
- width: options.width || upload.width || 512,
405
+ height: options.height || upload.height || (stkSize && stkSize.height) || 512,
406
+ width: options.width || upload.width || (stkSize && stkSize.width) || 512,
371
407
  directPath: upload.directPath,
372
408
  fileLength: upload.fileLength || 0,
373
409
  mediaKeyTimestamp: upload.mediaKeyTimestamp || Math.floor(Date.now() / 1000),
@@ -407,11 +443,22 @@ class MessageSender {
407
443
  question,
408
444
  options: options || [],
409
445
  selectableOptionsCount: selectableCount != null ? selectableCount : 0,
410
- encKey: opts.encKey || null
446
+ messageSecret: opts.messageSecret || opts.encKey || null
447
+ });
448
+ // whatsmeow's BuildPollCreation pairs the poll with a messageContextInfo
449
+ // holding the secret. It is a sibling field of the poll inside Message, not
450
+ // part of the poll — a voter derives their vote key from this copy, so a
451
+ // poll sent without it cannot be voted on.
452
+ const pollBuf = Buffer.concat([
453
+ encodeMessage('poll', result.payload),
454
+ encodeMessageContextInfo({ messageSecret: result.messageSecret })
455
+ ]);
456
+ const sendResult = await this._sendMessage(toJid, msgId, pollBuf, 'poll',
457
+ Object.assign({}, opts, { _encKey: result.messageSecret }));
458
+ return Object.assign({}, sendResult, {
459
+ encKey: result.messageSecret,
460
+ messageSecret: result.messageSecret
411
461
  });
412
- const pollBuf = encodeMessage('poll', result.payload);
413
- const sendResult = await this._sendMessage(toJid, msgId, pollBuf, 'poll', Object.assign({}, opts, { _encKey: result.encKey }));
414
- return Object.assign({}, sendResult, { encKey: result.encKey });
415
462
  }
416
463
 
417
464
  // ─── Location ─────────────────────────────────────────────────────────────
@@ -427,9 +474,19 @@ class MessageSender {
427
474
  longitude,
428
475
  name: opts.name || '',
429
476
  address: opts.address || '',
430
- url: opts.url || ''
477
+ url: opts.url || '',
478
+ isLive: opts.isLive || false,
479
+ accuracyInMeters: opts.accuracyInMeters,
480
+ // A map image for the bubble. WhatsApp draws a blank grey card without
481
+ // one, exactly as it does for a photo with no preview.
482
+ jpegThumbnail: opts.thumbnail || opts.jpegThumbnail || null,
483
+ contextInfo: opts.contextInfo
431
484
  }));
432
- return this._sendMessage(toJid, msgId, locBuf, 'location', opts);
485
+ // A location is a plain message as far as the envelope is concerned.
486
+ // whatsmeow's getTypeFromMessage has only text, media, reaction and poll —
487
+ // LocationMessage is in none of the media cases, so it falls through to
488
+ // text. Sending type="location" hands the server a type it has no case for.
489
+ return this._sendMessage(toJid, msgId, locBuf, 'text', opts);
433
490
  }
434
491
 
435
492
  // ─── Quoted Reply ─────────────────────────────────────────────────────────
@@ -455,8 +512,15 @@ class MessageSender {
455
512
  opts = opts || {};
456
513
  const msgId = opts.id || generateMessageId();
457
514
  const toJid = makeJid(to);
458
- const conBuf = encodeMessage('contact', encodeContactMessage({ displayName, vcard }));
459
- return this._sendMessage(toJid, msgId, conBuf, 'contact', opts);
515
+ const conBuf = encodeMessage('contact', encodeContactMessage({
516
+ displayName, vcard, contextInfo: opts.contextInfo
517
+ }));
518
+ // getMediaTypeFromMessage answers "vcard" for a ContactMessage, which makes
519
+ // getTypeFromMessage answer "media" — so the envelope is a media stanza and
520
+ // the enc nodes carry mediatype="vcard". type="contact" was not a type the
521
+ // server has a case for.
522
+ return this._sendMessage(toJid, msgId, conBuf, 'media',
523
+ Object.assign({}, opts, { _mediaSubtype: 'vcard' }));
460
524
  }
461
525
 
462
526
  // ─── Forward decoded message ───────────────────────────────────────────────
@@ -485,7 +549,10 @@ class MessageSender {
485
549
  url: d.url, directPath: d.directPath, mediaKey: d.mediaKey,
486
550
  fileSha256: d.fileSha256, fileEncSha256: d.fileEncSha256,
487
551
  fileLength: d.fileLength || 0, height: d.height || 0, width: d.width || 0,
488
- mimetype: d.mimetype || 'image/jpeg', caption: d.caption || ''
552
+ mimetype: d.mimetype || 'image/jpeg', caption: d.caption || '',
553
+ // Carry the sender's preview across: a forward re-uses their upload,
554
+ // so there are no bytes here to build a new one from.
555
+ jpegThumbnail: d.jpegThumbnail || null
489
556
  }
490
557
  }));
491
558
 
@@ -497,7 +564,8 @@ class MessageSender {
497
564
  fileSha256: d.fileSha256, fileEncSha256: d.fileEncSha256,
498
565
  fileLength: d.fileLength || 0, mimetype: d.mimetype || 'video/mp4',
499
566
  caption: d.caption || '', seconds: d.seconds || 0,
500
- height: d.height || 0, width: d.width || 0
567
+ height: d.height || 0, width: d.width || 0,
568
+ jpegThumbnail: d.jpegThumbnail || null
501
569
  }
502
570
  }));
503
571
 
@@ -522,7 +590,8 @@ class MessageSender {
522
590
  url: d.url, directPath: d.directPath, mediaKey: d.mediaKey,
523
591
  fileSha256: d.fileSha256, fileEncSha256: d.fileEncSha256,
524
592
  fileLength: d.fileLength || 0, mimetype: d.mimetype || 'application/octet-stream',
525
- title: d.fileName || 'file', fileName: d.fileName || 'file'
593
+ title: d.title || d.fileName || 'file', fileName: d.fileName || 'file',
594
+ jpegThumbnail: d.jpegThumbnail || null
526
595
  }
527
596
  }));
528
597
 
@@ -1432,7 +1501,9 @@ class MessageSender {
1432
1501
  key: { remoteJid: toJid, fromMe: !!fromMe, id: origMsgId }
1433
1502
  });
1434
1503
  const msgBuf = encodeMessage('protocol', revokePayload);
1435
- return this._sendMessage(toJid, msgId, msgBuf, 'protocol', { ...opts, edit: editBit });
1504
+ // ProtocolMessage is one of the explicit "text" cases in whatsmeow's
1505
+ // getTypeFromMessage; there is no "protocol" stanza type.
1506
+ return this._sendMessage(toJid, msgId, msgBuf, 'text', { ...opts, edit: editBit });
1436
1507
  }
1437
1508
 
1438
1509
  // ─── Ephemeral timer DM ───────────────────────────────────────────────────
@@ -1447,7 +1518,7 @@ class MessageSender {
1447
1518
  ephemeralExpiration: seconds
1448
1519
  });
1449
1520
  const msgBuf = encodeMessage('protocol', payload);
1450
- return this._sendMessage(toJid, msgId, msgBuf, 'protocol', opts);
1521
+ return this._sendMessage(toJid, msgId, msgBuf, 'text', opts);
1451
1522
  }
1452
1523
 
1453
1524
  // ─── Utility: mark read, presence, receipt, ping ─────────────────────────
@@ -1482,14 +1553,19 @@ class MessageSender {
1482
1553
 
1483
1554
  // ─── Media upload ─────────────────────────────────────────────────────────
1484
1555
 
1556
+ // Resolve a caller's media argument to bytes. Shared by the upload and the
1557
+ // thumbnail pass so a file on disk is read once, not twice.
1558
+ _toMediaBuffer(data) {
1559
+ if (Buffer.isBuffer(data)) return data;
1560
+ if (typeof data === 'string') return fs.readFileSync(data);
1561
+ throw new Error('Media must be a Buffer or file path');
1562
+ }
1563
+
1485
1564
  async _uploadMedia(mediaType, data) {
1486
1565
  if (this._isMediaConnExpired()) {
1487
1566
  await this._fetchMediaConn();
1488
1567
  }
1489
- let buf;
1490
- if (Buffer.isBuffer(data)) buf = data;
1491
- else if (typeof data === 'string') buf = fs.readFileSync(data);
1492
- else throw new Error('Media must be a Buffer or file path');
1568
+ const buf = this._toMediaBuffer(data);
1493
1569
  // Force-refresh callback: invoked by uploadMedia when a CDN host rejects the
1494
1570
  // auth token mid-upload, so the retry runs with fresh credentials instead of
1495
1571
  // failing with "connection expired".
@@ -197,15 +197,34 @@ function encodeStickerMessage(opts) {
197
197
  // field 2 = vote (PollVote — encrypted bytes)
198
198
  // field 3 = senderTimestampMs (int64)
199
199
 
200
+ // ─── MessageContextInfo (field 35 of Message) ────────────────────────────────
201
+ // MessageContextInfo { deviceListMetadata=1, deviceListMetadataVersion=2,
202
+ // messageSecret=3, ... }
203
+ //
204
+ // The message secret is what votes on a poll are encrypted against, so a poll
205
+ // sent without one can never be voted on.
206
+ function encodeMessageContextInfo(opts) {
207
+ const parts = [];
208
+ if (opts.messageSecret) parts.push(field(3, WIRE_LEN, bytes(opts.messageSecret)));
209
+ if (parts.length === 0) return Buffer.alloc(0);
210
+ return field(35, WIRE_LEN, Buffer.concat(parts));
211
+ }
212
+
200
213
  function encodePollOption(name) {
201
214
  return field(1, WIRE_LEN, str(name));
202
215
  }
203
216
 
217
+ // PollCreationMessage { encKey=1, name=2, options=3, selectableOptionsCount=4 }
218
+ //
219
+ // The 32-byte secret was being written to encKey, inside the poll itself.
220
+ // whatsmeow's BuildPollCreation leaves encKey unset and puts the secret in
221
+ // Message.messageContextInfo.messageSecret instead — which is the copy a voter
222
+ // derives their vote key from. In the wrong place it reached nobody, so the
223
+ // poll could not be voted on.
204
224
  function encodePollCreationMessage(opts) {
205
- // encKey: 32 random bytes (generate if not provided)
206
- const encKey = opts.encKey || require('crypto').randomBytes(32);
225
+ const messageSecret = opts.messageSecret || opts.encKey ||
226
+ require('crypto').randomBytes(32);
207
227
  const parts = [
208
- field(1, WIRE_LEN, bytes(encKey)),
209
228
  field(2, WIRE_LEN, str(opts.name || opts.question || '')),
210
229
  ];
211
230
  for (const opt of (opts.options || [])) {
@@ -214,12 +233,18 @@ function encodePollCreationMessage(opts) {
214
233
  if (opts.selectableOptionsCount != null) {
215
234
  parts.push(field(4, WIRE_VARINT, varint(opts.selectableOptionsCount)));
216
235
  }
217
- return { payload: Buffer.concat(parts), encKey };
236
+ // encKey is the historical name this returned it under; kept so callers that
237
+ // stored it keep working.
238
+ return { payload: Buffer.concat(parts), messageSecret, encKey: messageSecret };
218
239
  }
219
240
 
220
241
  // ─── LocationMessage (field 5 of Message) ────────────────────────────────────
221
242
  // LocationMessage { degreesLatitude=1(double), degreesLongitude=2(double),
222
- // name=3, address=4, url=5 }
243
+ // name=3, address=4, url=5, isLive=6, accuracyInMeters=7,
244
+ // jpegThumbnail=16, contextInfo=17 }
245
+ //
246
+ // jpegThumbnail is the map image drawn in the bubble. Without it the location
247
+ // arrives as a blank grey card, the same way a photo without a preview does.
223
248
  function encodeLocationMessage(opts) {
224
249
  const parts = [];
225
250
  if (opts.latitude != null) parts.push(encodeDouble(1, opts.latitude));
@@ -227,16 +252,24 @@ function encodeLocationMessage(opts) {
227
252
  if (opts.name) parts.push(field(3, WIRE_LEN, str(opts.name)));
228
253
  if (opts.address) parts.push(field(4, WIRE_LEN, str(opts.address)));
229
254
  if (opts.url) parts.push(field(5, WIRE_LEN, str(opts.url)));
255
+ if (opts.isLive) parts.push(field(6, WIRE_VARINT, bool(true)));
256
+ if (opts.accuracyInMeters != null) {
257
+ parts.push(field(7, WIRE_VARINT, varint(opts.accuracyInMeters)));
258
+ }
259
+ if (opts.jpegThumbnail) parts.push(field(16, WIRE_LEN, bytes(opts.jpegThumbnail)));
260
+ if (opts.contextInfo) parts.push(field(17, WIRE_LEN, encodeContextInfo(opts.contextInfo)));
230
261
  return Buffer.concat(parts);
231
262
  }
232
263
 
233
264
  // ─── ContactMessage (field 4 of Message) ─────────────────────────────────────
234
- // ContactMessage { displayName=1, vcard=16 }
265
+ // ContactMessage { displayName=1, vcard=16, contextInfo=17 }
235
266
  function encodeContactMessage(opts) {
236
- return Buffer.concat([
267
+ const parts = [
237
268
  field(1, WIRE_LEN, str(opts.displayName || opts.name || '')),
238
269
  field(16, WIRE_LEN, str(opts.vcard || ''))
239
- ]);
270
+ ];
271
+ if (opts.contextInfo) parts.push(field(17, WIRE_LEN, encodeContextInfo(opts.contextInfo)));
272
+ return Buffer.concat(parts);
240
273
  }
241
274
 
242
275
  function encodeReactionMessage(opts) {
@@ -373,6 +406,12 @@ function _str(buf) {
373
406
  try { return buf.toString('utf8'); } catch (_) { return ''; }
374
407
  }
375
408
 
409
+ // Varint fields come back from _decodeFields already as numbers; anything else
410
+ // (a missing field, or bytes where a number was expected) counts as zero.
411
+ function _num(v) {
412
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
413
+ }
414
+
376
415
  function _dbl(buf) {
377
416
  if (!buf || buf.length < 8) return 0;
378
417
  return buf.readDoubleLE(0);
@@ -409,6 +448,16 @@ function decodeMessageContainer(buf) {
409
448
  };
410
449
  }
411
450
 
451
+ // Field 35: messageContextInfo — carries the message secret a poll's votes
452
+ // are encrypted against. Attached to whatever message is decoded below.
453
+ let messageSecret = null;
454
+ if (f[35] && Buffer.isBuffer(f[35])) {
455
+ try {
456
+ const mci = _decodeFields(f[35]);
457
+ if (mci[3] && Buffer.isBuffer(mci[3])) messageSecret = mci[3];
458
+ } catch (_) {}
459
+ }
460
+
412
461
  // Decode the actual message payload (independent of SKDM).
413
462
  // We use a helper so we can attach skdmInfo to every result.
414
463
  let msgResult = null;
@@ -429,6 +478,13 @@ function decodeMessageContainer(buf) {
429
478
  }
430
479
 
431
480
  // Field 3: imageMessage
481
+ // url=1 mimetype=2 caption=3 fileSha256=4 fileLength=5 height=6 width=7
482
+ // mediaKey=8 fileEncSha256=9 directPath=11 jpegThumbnail=16
483
+ //
484
+ // Only a handful of these used to be kept. forwardDecodedMessage reads
485
+ // fileSha256, fileEncSha256, fileLength and the dimensions off this object,
486
+ // so a forwarded photo went out with them undefined — unverifiable for the
487
+ // recipient — and downloadMedia had no fileEncSha256 to check against.
432
488
  if (!msgResult && f[3] && Buffer.isBuffer(f[3])) {
433
489
  try {
434
490
  const img = _decodeFields(f[3]);
@@ -437,8 +493,14 @@ function decodeMessageContainer(buf) {
437
493
  caption: _str(img[3]),
438
494
  url: _str(img[1]),
439
495
  mimetype: _str(img[2]) || 'image/jpeg',
496
+ fileSha256: img[4] || null,
497
+ fileLength: _num(img[5]),
498
+ height: _num(img[6]),
499
+ width: _num(img[7]),
440
500
  mediaKey: img[8] || null,
501
+ fileEncSha256: img[9] || null,
441
502
  directPath: _str(img[11]),
503
+ jpegThumbnail: img[16] || null,
442
504
  };
443
505
  } catch (_) { msgResult = { type: 'image', caption: '' }; }
444
506
  }
@@ -469,14 +531,21 @@ function decodeMessageContainer(buf) {
469
531
  // Field 7: documentMessage
470
532
  if (!msgResult && f[7] && Buffer.isBuffer(f[7])) {
471
533
  try {
534
+ // url=1 mimetype=2 title=3 fileSha256=4 fileLength=5 mediaKey=7
535
+ // fileName=8 fileEncSha256=9 directPath=10 jpegThumbnail=16
472
536
  const doc = _decodeFields(f[7]);
473
537
  msgResult = {
474
538
  type: 'document',
475
539
  fileName: _str(doc[8]) || _str(doc[3]) || 'document',
540
+ title: _str(doc[3]),
476
541
  url: _str(doc[1]),
477
542
  mimetype: _str(doc[2]) || 'application/octet-stream',
543
+ fileSha256: doc[4] || null,
544
+ fileLength: _num(doc[5]),
478
545
  mediaKey: doc[7] || null,
546
+ fileEncSha256: doc[9] || null,
479
547
  directPath: _str(doc[10]),
548
+ jpegThumbnail: doc[16] || null,
480
549
  };
481
550
  } catch (_) { msgResult = { type: 'document', fileName: 'document' }; }
482
551
  }
@@ -484,12 +553,19 @@ function decodeMessageContainer(buf) {
484
553
  // Field 8: audioMessage
485
554
  if (!msgResult && f[8] && Buffer.isBuffer(f[8])) {
486
555
  try {
556
+ // url=1 mimetype=2 fileSha256=3 fileLength=4 seconds=5 ptt=6
557
+ // mediaKey=7 fileEncSha256=8 directPath=9
487
558
  const aud = _decodeFields(f[8]);
488
559
  msgResult = {
489
560
  type: aud[6] ? 'voice' : 'audio',
490
561
  url: _str(aud[1]),
491
562
  mimetype: _str(aud[2]) || 'audio/ogg; codecs=opus',
563
+ fileSha256: aud[3] || null,
564
+ fileLength: _num(aud[4]),
565
+ seconds: _num(aud[5]),
566
+ ptt: !!aud[6],
492
567
  mediaKey: aud[7] || null,
568
+ fileEncSha256: aud[8] || null,
493
569
  directPath: _str(aud[9]),
494
570
  };
495
571
  } catch (_) { msgResult = { type: 'audio' }; }
@@ -498,14 +574,25 @@ function decodeMessageContainer(buf) {
498
574
  // Field 9: videoMessage
499
575
  if (!msgResult && f[9] && Buffer.isBuffer(f[9])) {
500
576
  try {
577
+ // url=1 mimetype=2 fileSha256=3 fileLength=4 seconds=5 mediaKey=6
578
+ // caption=7 gifPlayback=8 height=9 width=10 fileEncSha256=11
579
+ // directPath=13 jpegThumbnail=16
501
580
  const vid = _decodeFields(f[9]);
502
581
  msgResult = {
503
582
  type: 'video',
504
583
  caption: _str(vid[7]),
505
584
  url: _str(vid[1]),
506
585
  mimetype: _str(vid[2]) || 'video/mp4',
586
+ fileSha256: vid[3] || null,
587
+ fileLength: _num(vid[4]),
588
+ seconds: _num(vid[5]),
507
589
  mediaKey: vid[6] || null,
590
+ gifPlayback: !!vid[8],
591
+ height: _num(vid[9]),
592
+ width: _num(vid[10]),
593
+ fileEncSha256: vid[11] || null,
508
594
  directPath: _str(vid[13]),
595
+ jpegThumbnail: vid[16] || null,
509
596
  };
510
597
  } catch (_) { msgResult = { type: 'video', caption: '' }; }
511
598
  }
@@ -549,13 +636,21 @@ function decodeMessageContainer(buf) {
549
636
  // Field 26: stickerMessage
550
637
  if (!msgResult && f[26] && Buffer.isBuffer(f[26])) {
551
638
  try {
639
+ // url=1 fileSha256=2 fileEncSha256=3 mediaKey=4 mimetype=5 height=6
640
+ // width=7 directPath=8 fileLength=9 isAnimated=13
552
641
  const stk = _decodeFields(f[26]);
553
642
  msgResult = {
554
643
  type: 'sticker',
555
644
  url: _str(stk[1]),
556
- mimetype: _str(stk[5]) || 'image/webp',
645
+ fileSha256: stk[2] || null,
646
+ fileEncSha256: stk[3] || null,
557
647
  mediaKey: stk[4] || null,
648
+ mimetype: _str(stk[5]) || 'image/webp',
649
+ height: _num(stk[6]),
650
+ width: _num(stk[7]),
558
651
  directPath: _str(stk[8]),
652
+ fileLength: _num(stk[9]),
653
+ isAnimated: !!stk[13],
559
654
  };
560
655
  } catch (_) { msgResult = { type: 'sticker' }; }
561
656
  }
@@ -587,6 +682,9 @@ function decodeMessageContainer(buf) {
587
682
  }
588
683
  const selectableOptionsCount = typeof poll[4] === 'number' ? poll[4] : 0;
589
684
  msgResult = { type: 'poll', question, options, selectableOptionsCount, encKey: poll[1] || null };
685
+ // Real clients leave encKey unset — the secret lives in
686
+ // messageContextInfo, picked up above and attached below.
687
+ if (!msgResult.encKey && messageSecret) msgResult.encKey = messageSecret;
590
688
  } catch (_) { msgResult = { type: 'poll', question: '', options: [] }; }
591
689
  }
592
690
 
@@ -595,8 +693,9 @@ function decodeMessageContainer(buf) {
595
693
  msgResult = { type: 'pollVote' };
596
694
  }
597
695
 
598
- // If we found a real message, attach skdmInfo (if any) and return
696
+ // If we found a real message, attach skdmInfo / messageSecret and return
599
697
  if (msgResult) {
698
+ if (messageSecret) msgResult = Object.assign({}, msgResult, { messageSecret });
600
699
  return skdmInfo ? Object.assign({}, msgResult, { skdm: skdmInfo }) : msgResult;
601
700
  }
602
701
 
@@ -645,6 +744,7 @@ module.exports = {
645
744
  encodeExtendedText,
646
745
  encodeContextInfo,
647
746
  encodeSenderKeyDistributionMessage,
747
+ encodeMessageContextInfo,
648
748
  encodeVarint,
649
749
  field,
650
750
  varint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.84",
3
+ "version": "5.5.86",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",
@@ -41,12 +41,15 @@
41
41
  "dependencies": {
42
42
  "@cacheable/node-cache": "^3.0.1",
43
43
  "@noble/hashes": "^1.7.2",
44
- "async-mutex": "^0.5.0",
44
+ "async-mutex": "^0.5.0",
45
45
  "axios": "^1.7.2",
46
46
  "curve25519-js": "^0.0.4",
47
47
  "dotenv": "^16.4.5",
48
48
  "pino": "^9.0.0",
49
49
  "protobufjs": "^6.11.4",
50
50
  "uuid": "^11.1.0"
51
+ },
52
+ "optionalDependencies": {
53
+ "jimp": "^1.6.0"
51
54
  }
52
55
  }