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 +37 -0
- package/lib/Client.js +68 -0
- package/lib/MediaThumbnail.js +394 -0
- package/lib/messages/MessageSender.js +106 -30
- package/lib/proto/MessageProto.js +110 -10
- package/package.json +5 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
459
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
}
|