whalibmob 5.29.1 → 5.29.2

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
@@ -3284,7 +3284,7 @@ connect()
3284
3284
  | `presence` | `{ from, available }` | Contact came online or went offline |
3285
3285
  | `group_update` | `{ type, groupJid, actor, participants, subject, timestamp }` | Member added / removed / promoted / demoted, subject or settings changed |
3286
3286
  | `notification` | node object | Group or contact update notification |
3287
- | `call` | `{ from }` | Incoming call event |
3287
+ | `call` | `{ from, id, status, tag, creator, creatorAlt, groupJid, platform, version, reason, ts, node }` | A call stanza. `id` is what `rejectCall()` needs, `creator` is who placed the call (it differs from `from` in a group), and `status` is the stage: `offer`, `offer_notice`, `ringing`, `accept`, `preaccept`, `transport`, `terminate`, `reject` or `unknown` |
3288
3288
  | `chat_read` | `{ jid, read, remote?, synced? }` | Chat marked read (`read: true`) or unread (`read: false`) |
3289
3289
  | `chat_muted` | `{ jid, muted, until, remote?, synced? }` | Chat muted or unmuted; `until` is epoch ms (−1 = indefinite) |
3290
3290
  | `chat_pinned` | `{ jid, pinned, remote?, synced? }` | Chat pinned or unpinned |
@@ -3322,9 +3322,21 @@ The message object contains:
3322
3322
  ts: number, // Unix timestamp (seconds)
3323
3323
  node: object, // raw XML node — node.attrs.sender_pn holds the real phone JID
3324
3324
  decoded: object, // structured payload — shape depends on message type (see below)
3325
+ fromMe: boolean, // you sent this from another of your own devices
3326
+ chat: string, // the conversation — equals from unless fromMe is true
3325
3327
  }
3326
3328
  ```
3327
3329
 
3330
+ > [!NOTE]
3331
+ > When you send a message from your phone, WhatsApp echoes a copy of it to every
3332
+ > other device on the account, wrapped in a `DeviceSentMessage`. On that copy
3333
+ > `from` is **your own JID** — it names the sender, not the chat. Use `chat`,
3334
+ > which is the conversation whichever direction the message went, and `fromMe`
3335
+ > to tell the two apart. The envelope's own fields are on
3336
+ > `decoded.deviceSentMeta` (`destinationJid`, `phash`) if you need them raw.
3337
+ > These messages are not answered with a read receipt: nothing was read by
3338
+ > receiving your own message back.
3339
+
3328
3340
  > [!NOTE]
3329
3341
  > WhatsApp Multi-Device uses **LID JIDs** internally. The `from` field may be a LID like
3330
3342
  > `112345678901234@s.whatsapp.net` rather than the real phone number. To get the actual
package/index.d.ts CHANGED
@@ -271,9 +271,23 @@ export interface DecodedBase {
271
271
  ephemeral?: boolean;
272
272
  /** The message is the new text of an edit. */
273
273
  edited?: boolean;
274
+ /**
275
+ * Present only on a message this account sent from one of its other devices.
276
+ * The stanza names us as the sender, so the chat it belongs to is written
277
+ * here and nowhere else.
278
+ */
279
+ deviceSentMeta?: DeviceSentMeta;
274
280
  [key: string]: any;
275
281
  }
276
282
 
283
+ /** What a `DeviceSentMessage` envelope said about the message inside it. */
284
+ export interface DeviceSentMeta {
285
+ /** The chat the message was originally addressed to. */
286
+ destinationJid?: Jid;
287
+ /** Hash of the participant list the sending device used, when it set one. */
288
+ phash?: string;
289
+ }
290
+
277
291
  export interface DecodedText extends DecodedBase {
278
292
  type: 'text';
279
293
  text: string;
@@ -335,6 +349,39 @@ export type DecodedMessage =
335
349
  | DecodedProtocol
336
350
  | DecodedBase;
337
351
 
352
+ /** The stage a call is at, from the child tag inside the `<call>` stanza. */
353
+ export type CallStatus =
354
+ | 'offer' | 'offer_notice' | 'ringing' | 'accept'
355
+ | 'preaccept' | 'transport' | 'terminate' | 'reject' | 'unknown';
356
+
357
+ /** The payload of the `call` event. */
358
+ export interface CallEvent {
359
+ /** Who the stanza came from. Equals `creator` on a one-to-one call. */
360
+ from: Jid;
361
+ /** The call id — what `rejectCall` needs. `null` on a stanza that names none. */
362
+ id: string | null;
363
+ /** `'ringing'` is the `relaylatency` stanza, under the name it has always had. */
364
+ status: CallStatus;
365
+ /** The child tag verbatim, so a stage not in `CallStatus` is still readable. */
366
+ tag: string;
367
+ /** The account that placed the call. Differs from `from` in a group. */
368
+ creator: Jid;
369
+ /** The creator's other name — phone JID for a LID creator, and the reverse. */
370
+ creatorAlt: Jid | null;
371
+ /** The group the call is in, when it is a group call. */
372
+ groupJid: Jid | null;
373
+ /** The caller's platform, on the stanzas that carry it. */
374
+ platform: string | null;
375
+ /** The caller's client version, on the stanzas that carry it. */
376
+ version: string | null;
377
+ /** Why the call ended, on a `terminate`. */
378
+ reason: string | null;
379
+ /** Unix timestamp, seconds. */
380
+ ts: number;
381
+ /** Raw binary node. */
382
+ node: any;
383
+ }
384
+
338
385
  /**
339
386
  * The payload of the `message` event.
340
387
  *
@@ -361,6 +408,17 @@ export interface IncomingMessage {
361
408
  text?: string | null;
362
409
  /** `true` on a group message. */
363
410
  isGroup?: boolean;
411
+ /**
412
+ * `true` when this account sent the message from another of its devices and
413
+ * the server echoed it back here.
414
+ */
415
+ fromMe?: boolean;
416
+ /**
417
+ * The conversation the message belongs to, whichever direction it went. Same
418
+ * as `from` for anything we received; for one of our own messages it is who
419
+ * we sent it to, which `from` cannot say.
420
+ */
421
+ chat?: Jid;
364
422
  }
365
423
 
366
424
  // ────────────────────────────────────────────────────────────────────────────
@@ -695,7 +753,7 @@ export interface WhalibmobEvents {
695
753
  message: (msg: IncomingMessage) => void;
696
754
  receipt: (r: { type: string; id: string; from: Jid }) => void;
697
755
  presence: (p: { from: Jid; available: boolean }) => void;
698
- call: (c: { from: Jid; id: string | null; status: 'offer' | 'ringing' | 'terminate' | 'unknown'; node: any }) => void;
756
+ call: (c: CallEvent) => void;
699
757
  notification: (node: any) => void;
700
758
  decrypt_error: (e: { id: string; from: Jid; participant?: Jid; err: Error }) => void;
701
759
  session_refresh: (e: { node: any }) => void;
@@ -936,7 +994,7 @@ export declare class WhalibmobClient extends EventEmitter {
936
994
 
937
995
  /**
938
996
  * Refuse one incoming call. Only reachable when the client was built with
939
- * `autoRejectCalls: false`; the id and caller come off the `call` event.
997
+ * `autoRejectCalls: false`; pass the `id` and `creator` off the `call` event.
940
998
  */
941
999
  rejectCall(callId: string, from: Jid): boolean;
942
1000
 
package/lib/Client.js CHANGED
@@ -166,6 +166,23 @@ const PRIVACY_WIRE_TO_KEY = {
166
166
  stickers: 'stickers'
167
167
  };
168
168
 
169
+ // The stage a call is at, keyed by the child tag the server puts inside <call>.
170
+ //
171
+ // These are the eight the protocol uses; anything else is reported as
172
+ // 'unknown' with the tag itself alongside, so a new one is visible rather than
173
+ // silently dropped. `relaylatency` keeps the name it has always been announced
174
+ // under here — it is the stanza that arrives while the phone is ringing.
175
+ const CALL_STATUS = {
176
+ offer: 'offer',
177
+ offer_notice: 'offer_notice',
178
+ relaylatency: 'ringing',
179
+ accept: 'accept',
180
+ preaccept: 'preaccept',
181
+ transport: 'transport',
182
+ terminate: 'terminate',
183
+ reject: 'reject'
184
+ };
185
+
169
186
  // Reduce a JID to its bare user. Blocking, and anything else that names an
170
187
  // account rather than one of its devices, wants this form.
171
188
  function toNonAdJid(input) {
@@ -2830,7 +2847,10 @@ class WhalibmobClient extends EventEmitter {
2830
2847
 
2831
2848
  this._sendMessageAck(id, fromRaw, partRaw);
2832
2849
  this._sendDeliveryReceipt(id, fromRaw, partRaw);
2833
- this.emit('message', { id, from, participant, ts, text, node });
2850
+ this.emit('message', {
2851
+ id, from, participant, ts, text, node,
2852
+ fromMe: false, chat: from
2853
+ });
2834
2854
  this._sendReadReceipt(id, fromRaw, partRaw);
2835
2855
  }
2836
2856
 
@@ -3111,8 +3131,24 @@ class WhalibmobClient extends EventEmitter {
3111
3131
  // If we had asked the phone to resend this one, it has answered.
3112
3132
  this._resolvePlaceholderResend(id);
3113
3133
 
3114
- this.emit('message', { id, from, participant, ts, decoded, node });
3115
- this._sendReadReceipt(id, fromRaw, partRaw);
3134
+ // A DeviceSentMessage is one we sent ourselves from another device. The
3135
+ // stanza's `from` is our own JID, so it says who but never where: the
3136
+ // chat is named inside the envelope and nowhere else. Both are put on
3137
+ // the event — `chat` is the conversation whichever direction the message
3138
+ // went, so a handler can answer without having to know which case it is
3139
+ // looking at.
3140
+ const dsm = decoded && decoded.deviceSentMeta;
3141
+ const dest = dsm && dsm.destinationJid ? String(dsm.destinationJid) : null;
3142
+ this.emit('message', {
3143
+ id, from, participant, ts, decoded, node,
3144
+ fromMe: !!dest,
3145
+ chat: dest || from
3146
+ });
3147
+
3148
+ // Nothing is read by receiving our own message back. A read receipt for
3149
+ // it would be addressed to ourselves and marks the chat read on every
3150
+ // other device — which is not what happened.
3151
+ if (!dest) this._sendReadReceipt(id, fromRaw, partRaw);
3116
3152
 
3117
3153
  // ── pkmsg = peer opened a new Signal session (identity/device change) ──
3118
3154
  // Re-issue our tcToken to them so the fresh session carries a valid
@@ -3153,7 +3189,10 @@ class WhalibmobClient extends EventEmitter {
3153
3189
  }
3154
3190
  return;
3155
3191
  }
3156
- this.emit('message', { id, from, participant, ts, decoded, isGroup: true, node });
3192
+ this.emit('message', {
3193
+ id, from, participant, ts, decoded, isGroup: true, node,
3194
+ fromMe: false, chat: from
3195
+ });
3157
3196
  this._sendReadReceipt(id, fromRaw, partRaw);
3158
3197
  })
3159
3198
  .catch(err => {
@@ -4185,7 +4224,8 @@ class WhalibmobClient extends EventEmitter {
4185
4224
  // A <call> can also be the answer to something we asked for — creating a
4186
4225
  // call link, for one. Those come back on the same tag with the id we sent,
4187
4226
  // so they have to be handed to the waiting caller and not treated as an
4188
- // incoming call to reject.
4227
+ // incoming call to reject. A reply to us is not a server push and gets no
4228
+ // ack, which is why this stays ahead of everything below.
4189
4229
  if (attrs.id) {
4190
4230
  const handler = this._pendingIqs.get(attrs.id);
4191
4231
  if (handler) {
@@ -4195,23 +4235,63 @@ class WhalibmobClient extends EventEmitter {
4195
4235
  }
4196
4236
  }
4197
4237
 
4238
+ // The server expects to hear that the stanza arrived, for a call exactly as
4239
+ // it does for a message or a notification. Nothing here acknowledged one, so
4240
+ // every incoming call was re-delivered on the next connection — and it goes
4241
+ // out first, before the shape below is looked at, because an unrecognised
4242
+ // call is still a call that reached us.
4243
+ this._sendStanzaAck(node);
4244
+
4198
4245
  // What the stanza actually says, rather than the raw node alone: which call
4199
4246
  // it is, and what stage it is at. A caller deciding whether to reject needs
4200
4247
  // both, and digging them out of the node is not its job.
4201
- const offer = findChild(node, 'offer');
4202
- const relay = findChild(node, 'relaylatency');
4203
- const term = findChild(node, 'terminate');
4204
- const callId = (offer && offer.attrs && offer.attrs.call_id) ||
4205
- (term && term.attrs && term.attrs.call_id) || null;
4206
- const status = offer ? 'offer' : (term ? 'terminate' : (relay ? 'ringing' : 'unknown'));
4207
-
4208
- this.emit('call', { from: attrs.from || '', id: callId, status, node });
4248
+ //
4249
+ // All of it lives on the single child, not on the <call> itself, and under
4250
+ // hyphenated names: `call-id` and `call-creator`. This read `call_id` off
4251
+ // the child, which no stanza has ever carried — so the id was always null,
4252
+ // the event announced a call nobody could identify, and the automatic
4253
+ // refusal below was skipped for every call that ever came in. Both spellings
4254
+ // are in the binary token table and only the hyphenated ones are real.
4255
+ const children = Array.isArray(node.content)
4256
+ ? node.content.filter(c => c && c.description) : [];
4257
+ const child = children.length === 1 ? children[0] : null;
4258
+ const cattrs = (child && child.attrs) || {};
4259
+ const tag = child ? child.description : '';
4260
+
4261
+ const from = attrs.from ? String(attrs.from) : '';
4262
+ const callId = cattrs['call-id'] ? String(cattrs['call-id']) : null;
4263
+ // The creator is the account that placed the call. On a one-to-one call it
4264
+ // is the same as the stanza's `from`; in a group they differ, and the
4265
+ // refusal has to name the creator.
4266
+ const creator = cattrs['call-creator'] ? String(cattrs['call-creator']) : from;
4267
+ const status = CALL_STATUS[tag] || 'unknown';
4268
+
4269
+ // The caller's other name. A LID creator carries its phone number, a phone
4270
+ // creator its LID — whichever side the stanza did not use to address it.
4271
+ const creatorAlt = creator.endsWith('@lid')
4272
+ ? (cattrs.caller_pn ? String(cattrs.caller_pn) : null)
4273
+ : (cattrs.caller_lid ? String(cattrs.caller_lid) : null);
4274
+
4275
+ this.emit('call', {
4276
+ from,
4277
+ id: callId,
4278
+ status,
4279
+ tag,
4280
+ creator,
4281
+ creatorAlt,
4282
+ groupJid: cattrs['group-jid'] ? String(cattrs['group-jid']) : null,
4283
+ platform: attrs.platform ? String(attrs.platform) : null,
4284
+ version: attrs.version ? String(attrs.version) : null,
4285
+ reason: cattrs.reason ? String(cattrs.reason) : null,
4286
+ ts: attrs.t ? parseInt(attrs.t, 10) : 0,
4287
+ node
4288
+ });
4209
4289
 
4210
4290
  // Reject every incoming call, unless the caller asked to decide for itself.
4211
4291
  // Turning this off does not answer calls — nothing here can — it only stops
4212
4292
  // the automatic refusal, so a caller can reject some and let the rest ring.
4213
4293
  if (this._autoRejectCalls && callId && status === 'offer') {
4214
- this.rejectCall(callId, attrs.from || '');
4294
+ this.rejectCall(callId, creator);
4215
4295
  }
4216
4296
  }
4217
4297
 
@@ -4220,21 +4300,50 @@ class WhalibmobClient extends EventEmitter {
4220
4300
  *
4221
4301
  * Only needed when the client was built with `autoRejectCalls: false`; with
4222
4302
  * the default every call is refused before this could be reached. The call id
4223
- * and the caller both come off the `call` event.
4303
+ * and the caller both come off the `call` event — `id` and `creator`.
4224
4304
  *
4225
4305
  * @param {string} callId the `id` from the call event
4226
- * @param {string} from the `from` from the call event
4306
+ * @param {string} from the `creator` from the call event
4227
4307
  * @returns {boolean} whether the refusal could be sent
4228
4308
  */
4229
4309
  rejectCall(callId, from) {
4230
4310
  if (!callId || !this._socket || !this._connected) return false;
4311
+ // Both ends are named without their device: a call belongs to an account,
4312
+ // not to the device that happened to place it.
4313
+ const creator = toNonAdJid(String(from || ''));
4314
+ const ownJid = toNonAdJid(this._ownJidFor(creator));
4231
4315
  this._socket.sendNode(new BinaryNode('call', {
4232
- to: from || '',
4233
- id: this._genMsgId()
4234
- }, [new BinaryNode('reject', { call_id: callId, call_creator: from || '' }, null)]));
4316
+ id: this._genMsgId(),
4317
+ from: jidStrToObj(ownJid),
4318
+ to: jidStrToObj(creator)
4319
+ }, [new BinaryNode('reject', {
4320
+ 'call-id': callId,
4321
+ 'call-creator': jidStrToObj(creator),
4322
+ count: '0'
4323
+ }, null)]));
4235
4324
  return true;
4236
4325
  }
4237
4326
 
4327
+ // The acknowledgement a server push gets, whatever tag it came in on.
4328
+ //
4329
+ // Mirrors the one whatsmeow sends: the stanza's own tag as the class, its id,
4330
+ // and back to whoever sent it, carrying `participant` and `recipient` through
4331
+ // when the stanza named them. `type` rides along for everything but a message,
4332
+ // where it means something else entirely.
4333
+ _sendStanzaAck(node) {
4334
+ if (!this._socket || !this._connected) return;
4335
+ const attrs = node.attrs || {};
4336
+ const ack = {
4337
+ id: attrs.id || '',
4338
+ class: node.description,
4339
+ to: attrs.from || 's.whatsapp.net'
4340
+ };
4341
+ if (attrs.participant) ack.participant = attrs.participant;
4342
+ if (attrs.recipient) ack.recipient = attrs.recipient;
4343
+ if (attrs.type && node.description !== 'message') ack.type = String(attrs.type);
4344
+ this._socket.sendNode(new BinaryNode('ack', ack, null));
4345
+ }
4346
+
4238
4347
  // ─── Auth failure (server forces logout during active session) ──────────────
4239
4348
 
4240
4349
  // <failure> ends the connection, but not every reason means the session died.
@@ -1026,13 +1026,13 @@ class MessageSender {
1026
1026
  // set than the server's.
1027
1027
  //
1028
1028
  // It is not put on the stanza: phash belongs on a group message, not a 1:1
1029
- // one. It is carried inside the (encrypted)
1030
- // DeviceSentMessage for our own devices, and compared against the phash the
1031
- // server echoes back on the ack so a drifted device cache gets flushed.
1029
+ // one. It is kept only to be compared against the phash the server echoes
1030
+ // back on the ack, so a drifted device cache gets flushed — see the check
1031
+ // after the dispatch below.
1032
1032
  const phash = computePhash([...otherJids, ownPrimaryJid, ...ownLinkedJids]);
1033
1033
 
1034
1034
  const dsmBuf = ownLinkedJids.length > 0
1035
- ? encodeDeviceSentMessage(toJid, plaintext, phash)
1035
+ ? encodeDeviceSentMessage(toJid, plaintext)
1036
1036
  : null;
1037
1037
 
1038
1038
  // Acquire mutex before touching Signal sessions — concurrent sends on the
@@ -353,14 +353,32 @@ function encodeMessage(type, payload) {
353
353
  // Used when sending to own linked devices — wraps the original message so other
354
354
  // devices know where the message was originally addressed (destinationJid).
355
355
  // DeviceSentMessage { destinationJid=1, message=2, phash=3 }
356
-
357
- function encodeDeviceSentMessage(destinationJid, messageBuf, phash) {
356
+ //
357
+ // The envelope is not the whole outer Message. Whatever messageContextInfo the
358
+ // message carries is copied up beside the envelope as well, because that is
359
+ // where a recipient reads it from: it is a field of Message, and once the
360
+ // message is buried inside a DeviceSentMessage the copy inside it is a level
361
+ // too deep for anything that does not know to go looking. A poll echoed to our
362
+ // own devices reached them without a message secret, so none of them could ever
363
+ // decrypt a vote on it. whatsmeow (`MessageContextInfo: message.MessageContextInfo`
364
+ // in marshalMessage) and Baileys both duplicate it for the same reason.
365
+ //
366
+ // phash is left unset, as it is by every other implementation: it describes the
367
+ // participant list, the server checks that against the hash on the stanza, and
368
+ // no client has ever read it back out of here.
369
+ function encodeDeviceSentMessage(destinationJid, messageBuf) {
358
370
  const inner = Buffer.concat([
359
371
  field(1, WIRE_LEN, str(destinationJid)),
360
- field(2, WIRE_LEN, messageBuf),
361
- phash ? field(3, WIRE_LEN, str(phash)) : Buffer.alloc(0)
372
+ field(2, WIRE_LEN, messageBuf)
362
373
  ]);
363
- return field(31, WIRE_LEN, inner);
374
+
375
+ let contextInfo = Buffer.alloc(0);
376
+ try {
377
+ const mci = _decodeFields(messageBuf)[35];
378
+ if (Buffer.isBuffer(mci) && mci.length) contextInfo = field(35, WIRE_LEN, mci);
379
+ } catch (_) { /* nothing to lift */ }
380
+
381
+ return Buffer.concat([field(31, WIRE_LEN, inner), contextInfo]);
364
382
  }
365
383
 
366
384
  // ─── ProtocolMessage (field 12 of Message) ───────────────────────────────────
@@ -733,6 +751,9 @@ function decodeMessageContainer(buf, depth) {
733
751
  // still belongs to what comes out, which is why both are read first and
734
752
  // carried onto the result — unwrapping used to return straight out of here
735
753
  // and lose them.
754
+ // What a DeviceSentMessage envelope said about itself, kept for the caller.
755
+ let deviceSentMeta = null;
756
+
736
757
  if (level < MAX_WRAPPER_DEPTH) {
737
758
  for (const w of MESSAGE_WRAPPERS) {
738
759
  const inner = _unwrap(f[w.field], w.inner);
@@ -744,6 +765,24 @@ function decodeMessageContainer(buf, depth) {
744
765
  if (!decoded || decoded.type === 'unknown') continue;
745
766
  msgResult = decoded;
746
767
  if (w.flag) envelopeFlags = Object.assign({}, envelopeFlags, { [w.flag]: true });
768
+ // A DeviceSentMessage is a message this account sent from one of its
769
+ // other devices, echoed back to us. The stanza's `from` is therefore our
770
+ // own JID and names nothing: the conversation it belongs to is only ever
771
+ // written here, in the envelope. It was being opened and thrown away, so
772
+ // a message sent from the phone arrived with no way to tell which chat
773
+ // it was in. whatsmeow keeps the same two fields as Info.DeviceSentMeta.
774
+ if (w.field === 31) {
775
+ try {
776
+ const env = _decodeFields(f[31]);
777
+ const dest = _str(env[1]);
778
+ const ph = _str(env[3]);
779
+ if (dest || ph) {
780
+ deviceSentMeta = {};
781
+ if (dest) deviceSentMeta.destinationJid = dest;
782
+ if (ph) deviceSentMeta.phash = ph;
783
+ }
784
+ } catch (_) { /* the envelope named nothing */ }
785
+ }
747
786
  break;
748
787
  }
749
788
  }
@@ -1017,6 +1056,9 @@ function decodeMessageContainer(buf, depth) {
1017
1056
  // this level only fills in what is still missing.
1018
1057
  if (msgResult) {
1019
1058
  if (envelopeFlags) msgResult = Object.assign({}, msgResult, envelopeFlags);
1059
+ if (deviceSentMeta && !msgResult.deviceSentMeta) {
1060
+ msgResult = Object.assign({}, msgResult, { deviceSentMeta });
1061
+ }
1020
1062
  if (messageSecret && !msgResult.messageSecret) {
1021
1063
  msgResult = Object.assign({}, msgResult, { messageSecret });
1022
1064
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.29.1",
3
+ "version": "5.29.2",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",