@oxidezap/baileyrs 0.2.8 → 0.2.9

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.
@@ -102,11 +102,18 @@ function tcTokenCodec() {
102
102
  },
103
103
  toLegacy(_key, value) {
104
104
  const native = fromJsonBytes(value);
105
- return {
105
+ // Added rather than set to `undefined`, matching upstream, which
106
+ // only carries the field when it has a value. An undefined-valued
107
+ // key survives in memory but not through a store, and the mirror
108
+ // fingerprint is taken on both sides of that boundary — see the
109
+ // note in `canonicalize`.
110
+ const legacy = {
106
111
  token: Buffer.from(requireByteArray(native.token, 'native TC token')),
107
- timestamp: (native.token_timestamp ?? TimeValue.UNKNOWN_SECONDS).toString(),
108
- senderTimestamp: native.sender_timestamp == null ? undefined : native.sender_timestamp.toString()
112
+ timestamp: (native.token_timestamp ?? TimeValue.UNKNOWN_SECONDS).toString()
109
113
  };
114
+ if (native.sender_timestamp != null)
115
+ legacy.senderTimestamp = native.sender_timestamp.toString();
116
+ return legacy;
110
117
  }
111
118
  };
112
119
  }
@@ -177,7 +177,16 @@ function coreEntryToLegacy(session) {
177
177
  messageKeys: messageKeysToLegacy(chain.messageKeys)
178
178
  };
179
179
  }
180
- return {
180
+ // Both optional parts are *added* rather than set to `undefined`, which is
181
+ // what upstream does (`session_record.js` guards `if (this.pendingPreKey)`,
182
+ // `session_builder.js` guards `if (device.preKey)`). The distinction is
183
+ // invisible once the value reaches a store — JSON drops an undefined-valued
184
+ // key — but it is visible to the mirror fingerprint, which is taken from
185
+ // the live object on write and from the reloaded one on read. Emitting the
186
+ // key made those two disagree for every session without a pending pre-key,
187
+ // so the mirror was rejected on the next read and the record rebuilt from
188
+ // the projection, discarding the counter lease the rebuild cannot express.
189
+ const entry = {
181
190
  registrationId: session.registrationId,
182
191
  currentRatchet: {
183
192
  ephemeralKeyPair: {
@@ -196,15 +205,18 @@ function coreEntryToLegacy(session) {
196
205
  created: session.index.createdAtMs,
197
206
  remoteIdentityKey: toBase64(session.index.remoteIdentityKey)
198
207
  },
199
- _chains: chains,
200
- pendingPreKey: session.pendingPreKey
201
- ? {
202
- preKeyId: session.pendingPreKey.preKeyId,
203
- signedKeyId: session.pendingPreKey.signedPreKeyId,
204
- baseKey: toBase64(session.pendingPreKey.baseKey)
205
- }
206
- : undefined
208
+ _chains: chains
207
209
  };
210
+ if (session.pendingPreKey) {
211
+ entry.pendingPreKey = {
212
+ signedKeyId: session.pendingPreKey.signedPreKeyId,
213
+ baseKey: toBase64(session.pendingPreKey.baseKey)
214
+ };
215
+ if (session.pendingPreKey.preKeyId !== undefined) {
216
+ entry.pendingPreKey.preKeyId = session.pendingPreKey.preKeyId;
217
+ }
218
+ }
219
+ return entry;
208
220
  }
209
221
  function sessionToLegacy(nativeBytes) {
210
222
  const projection = projectLegacySessionRecordV1(nativeBytes);
@@ -16,15 +16,29 @@ export const toBytes = (value) => {
16
16
  return null;
17
17
  };
18
18
  export const bytesToNumbers = (value) => Array.from(value);
19
+ /**
20
+ * Marks a value JSON would not have kept, so the object and array branches can
21
+ * each drop it the way `JSON.stringify` does. A plain `undefined` return could
22
+ * not be told apart from a property that is genuinely absent.
23
+ */
24
+ const ABSENT = Symbol('absent');
19
25
  /**
20
26
  * Stable, runtime-independent representation used only to notice legacy-side
21
27
  * ownership changes. Buffer and Uint8Array intentionally share one shape.
28
+ *
29
+ * `undefined` follows `JSON.stringify`, and that is deliberately asymmetric: an
30
+ * object property is dropped, an array element becomes `null`. The fingerprint
31
+ * is taken from the live projection when the mirror is written and from the
32
+ * reloaded one when it is read, so any distinction a store cannot carry makes
33
+ * the two disagree forever. Tagging `undefined` as its own value was such a
34
+ * distinction — no persisted store can express it, and the mirror it invalidated
35
+ * is the only copy of the record fields the projection has no room for.
22
36
  */
23
37
  function canonicalize(value, seen) {
24
38
  if (value === null)
25
39
  return null;
26
40
  if (value === undefined)
27
- return { [CanonicalTag.UNDEFINED]: true };
41
+ return ABSENT;
28
42
  if (typeof value === 'string' || typeof value === 'boolean')
29
43
  return value;
30
44
  if (typeof value === 'number') {
@@ -51,11 +65,17 @@ function canonicalize(value, seen) {
51
65
  throw new TypeError('cannot fingerprint a cyclic legacy store value');
52
66
  seen.add(value);
53
67
  try {
54
- if (Array.isArray(value))
55
- return value.map(item => canonicalize(item, seen));
68
+ if (Array.isArray(value)) {
69
+ return value.map(item => {
70
+ const canonical = canonicalize(item, seen);
71
+ return canonical === ABSENT ? null : canonical;
72
+ });
73
+ }
56
74
  const out = {};
57
75
  for (const key of Object.keys(value).toSorted()) {
58
- out[key] = canonicalize(value[key], seen);
76
+ const canonical = canonicalize(value[key], seen);
77
+ if (canonical !== ABSENT)
78
+ out[key] = canonical;
59
79
  }
60
80
  return out;
61
81
  }
@@ -64,8 +84,13 @@ function canonicalize(value, seen) {
64
84
  }
65
85
  }
66
86
  function fingerprintLegacy(value) {
67
- const canonical = JSON.stringify(canonicalize(value, new Set()));
68
- return createHash(MirrorEnvelope.HASH).update(canonical).digest();
87
+ // A top-level absent value hashes as `null`: a store reports a row it does
88
+ // not hold as either, and the two have to agree for the same reason the
89
+ // property case does.
90
+ const canonical = canonicalize(value, new Set());
91
+ return createHash(MirrorEnvelope.HASH)
92
+ .update(JSON.stringify(canonical === ABSENT ? null : canonical))
93
+ .digest();
69
94
  }
70
95
  /** Persist exact native bytes together with the projection generation. */
71
96
  export function encodeNativeEnvelope(payload, legacyValue) {
@@ -101,7 +101,6 @@ export declare const CanonicalTag: Readonly<{
101
101
  readonly BYTES: '$bytes';
102
102
  readonly DATE: '$date';
103
103
  readonly NUMBER: '$number';
104
- readonly UNDEFINED: '$undefined';
105
104
  readonly UNSUPPORTED: '$unsupported';
106
105
  }>;
107
106
  export declare const NumericEncoding: Readonly<{
@@ -99,7 +99,6 @@ export const CanonicalTag = Object.freeze({
99
99
  BYTES: '$bytes',
100
100
  DATE: '$date',
101
101
  NUMBER: '$number',
102
- UNDEFINED: '$undefined',
103
102
  UNSUPPORTED: '$unsupported'
104
103
  });
105
104
  export const NumericEncoding = Object.freeze({
@@ -112,6 +112,8 @@ export type PollMessageOptions = {
112
112
  name: string;
113
113
  selectableCount?: number;
114
114
  values: string[];
115
+ /** 32 byte message secret to encrypt poll selections */
116
+ messageSecret?: Uint8Array;
115
117
  toAnnouncementGroup?: boolean;
116
118
  };
117
119
  export type EventMessageOptions = {
@@ -8,6 +8,7 @@ import { proto } from '../WAProto/runtime.js';
8
8
  import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js';
9
9
  import { assertArgumentDomain } from './argument-domain.js';
10
10
  import { Boom } from './boom.js';
11
+ import { randomBytes } from 'node:crypto';
11
12
  import { sha256 } from './crypto.js';
12
13
  import { getKeyAuthor, toNumber, unixTimestampSeconds } from './generics.js';
13
14
  import { generateThumbnail, getAudioDuration, getAudioWaveform, getStream, toBuffer } from './messages-media.js';
@@ -434,6 +435,31 @@ export const generateWAMessageContent = async (message, options) => {
434
435
  m.eventMessage.extraGuestsAllowed = message.event.extraGuestsAllowed;
435
436
  m.eventMessage.isScheduleCall = message.event.isScheduleCall ?? false;
436
437
  m.eventMessage.location = message.event.location;
438
+ // The sender's copy of the key that encrypts responses to this event;
439
+ // `decryptEventResponse` takes it as `eventEncKey`. WhatsApp Web writes it
440
+ // on the sender at creation (`WAWebSendEventCreationMsgAction`) and refuses
441
+ // to build an event edit without it (`WAWebCreateEncryptedEventEditMsgData`
442
+ // throws `EventCreationValidationError`).
443
+ //
444
+ // The core settles `messageSecret` on relay for messages that reach it
445
+ // without one — see the comment in `Socket/messages.ts` — but it settles it
446
+ // on the wire, where the caller never sees it. That is right for the
447
+ // reporting token and wrong for a key the caller has to keep.
448
+ //
449
+ // Written after `m.eventMessage`, for the reason spelled out on the poll
450
+ // branch below: the blocks that attach `contextInfo` take `Object.keys(m)[0]`
451
+ // as the content key. `event` is not `Mentionable` or `Contextable` today,
452
+ // so this is ordering that stays correct rather than ordering that is
453
+ // currently load-bearing — but it costs nothing and the poll branch shows
454
+ // what getting it wrong looks like.
455
+ //
456
+ // Spread rather than upstream's wholesale assignment: nothing else writes
457
+ // `messageContextInfo` on this branch today, so the two cannot differ, and
458
+ // this cannot silently drop a field a later change puts there.
459
+ m.messageContextInfo = {
460
+ ...m.messageContextInfo,
461
+ messageSecret: message.event.messageSecret || randomBytes(32)
462
+ };
437
463
  }
438
464
  else if (hasNonNullishProperty(message, 'poll')) {
439
465
  (_a = message.poll).selectableCount || (_a.selectableCount = 0);
@@ -465,6 +491,29 @@ export const generateWAMessageContent = async (message, options) => {
465
491
  m.pollCreationMessage = pollCreationMessage;
466
492
  }
467
493
  }
494
+ // The key that encrypts the votes. `decryptPollVote` takes it as
495
+ // `pollEncKey`, and without it here the poll goes out fine but its own
496
+ // sender can never read the votes: the copy they store — the message
497
+ // `sendMessage` returns and emits as `messages.upsert` — is this object,
498
+ // and only `key.id` is patched from the relay result. WhatsApp Web writes it
499
+ // on the sender at creation too (`WAWebPollsSendPollCreationMsgAction`) and
500
+ // reuses the stored one for poll edits
501
+ // (`WAWebPollsGeneratePollEditMessageProto`).
502
+ //
503
+ // After the payload, not before, and that ordering is load-bearing: the
504
+ // `mentions` and `contextInfo` blocks below pick their target with
505
+ // `Object.keys(m)[0]`, so a `messageContextInfo` written first becomes the
506
+ // content key they attach to. `contextInfo` is not a field of
507
+ // `IMessageContextInfo`, so the encoder drops it and a poll sent with
508
+ // mentions loses them silently. `poll` is `Mentionable & Contextable`, so
509
+ // that is a combination callers can and do write. Upstream assigns it
510
+ // before the payload and has exactly that bug; measured on
511
+ // `{poll, mentions:['x@s.whatsapp.net']}`, its output carries no
512
+ // `mentionedJid` at all.
513
+ m.messageContextInfo = {
514
+ ...m.messageContextInfo,
515
+ messageSecret: message.poll.messageSecret || randomBytes(32)
516
+ };
468
517
  }
469
518
  else if (hasNonNullishProperty(message, 'sharePhoneNumber')) {
470
519
  m.protocolMessage = {
@@ -24,19 +24,53 @@ export const isRealMessage = (message) => {
24
24
  !normalizedContent?.pollUpdateMessage);
25
25
  };
26
26
  export const shouldIncrementChatUnread = (message) => !message.key.fromMe && !message.messageStubType;
27
+ /**
28
+ * WhatsApp Web's rules, which upstream Baileys does not always follow.
29
+ *
30
+ * **A field with nothing to normalise stays absent.** `WAWebMsgKey` only assigns
31
+ * the participant it was given — `h !== void 0 && (this.participant = h)` — and
32
+ * builds the message's identity by joining the parts that are present, so an
33
+ * empty string there would change what the key serialises to. Upstream runs
34
+ * every field through `jidNormalizedUser`, which answers `''` for a jid it was
35
+ * not given, and so writes `participant: ''` onto every direct-message key.
36
+ * Matching that would mean copying a bug into the key handed back to callers.
37
+ *
38
+ * **A hosted jid is re-encoded onto its plain server**, and the fallback that
39
+ * used to keep `@hosted` when the user part was empty is gone. WA Web accepts
40
+ * hosted only as `<digits>:99@hosted` (`WAWebWidValidator`), and every jid that
41
+ * reached the old fallback is one it rejects outright — so there is no
42
+ * behaviour to preserve there, and agreeing with upstream costs nothing. Valid
43
+ * hosted jids already agreed.
44
+ */
27
45
  const normalizeMessageJid = (jid) => {
28
46
  if (!jid)
29
47
  return undefined;
30
48
  const hostedPn = isHostedPnUser(jid);
31
49
  if (!hostedPn && !isHostedLidUser(jid))
32
50
  return jidNormalizedUser(jid);
33
- const user = jidDecode(jid)?.user;
34
- return user ? jidEncode(user, hostedPn ? 's.whatsapp.net' : 'lid') : jidNormalizedUser(jid);
51
+ return jidEncode(jidDecode(jid)?.user ?? null, hostedPn ? 's.whatsapp.net' : 'lid');
35
52
  };
36
53
  /** Normalize device/hosted JIDs and nested reaction/poll keys in place. */
37
54
  export const cleanMessage = (message, meId, meLid) => {
38
- message.key.remoteJid = normalizeMessageJid(message.key.remoteJid);
39
- message.key.participant = normalizeMessageJid(message.key.participant);
55
+ // Normalise the fields that are there; do not add the ones that are not.
56
+ //
57
+ // Writing the result unconditionally put the property on the key even when
58
+ // there was nothing to normalise, so a direct-message key — which carries no
59
+ // participant — still gained one holding `undefined`, and `Object.keys` and a
60
+ // spread saw it. `WAWebMsgKey` assigns the participant it was given and
61
+ // leaves the key without one otherwise, and that absence is load-bearing
62
+ // there: it joins the parts that are present into the id the message is
63
+ // stored under.
64
+ //
65
+ // A field the caller did provide is normalised even when it comes back with
66
+ // nothing, which is the empty string upstream writes — they provided it, so
67
+ // it is theirs to have normalised, and leaving a `null` in place would only
68
+ // swap one falsy spelling for another.
69
+ for (const field of ['remoteJid', 'participant']) {
70
+ if (!Object.hasOwn(message.key, field) && message.key[field] == null)
71
+ continue;
72
+ message.key[field] = normalizeMessageJid(message.key[field]) ?? '';
73
+ }
40
74
  const content = normalizeMessageContent(message.message);
41
75
  if (content?.reactionMessage)
42
76
  normaliseKey(content.reactionMessage.key);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxidezap/baileyrs",
3
3
  "type": "module",
4
- "version": "0.2.8",
4
+ "version": "0.2.9",
5
5
  "description": "A Rust-powered WhatsApp Web library for JavaScript, with a Baileys-compatible API",
6
6
  "keywords": [
7
7
  "whatsapp",