@oxidezap/baileyrs 0.2.8 → 0.2.10
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 +13 -0
- package/lib/Compatibility/legacy-store/codecs/basic.js +10 -3
- package/lib/Compatibility/legacy-store/codecs/signal.js +21 -9
- package/lib/Compatibility/legacy-store/common.js +31 -6
- package/lib/Compatibility/legacy-store/constants.d.ts +0 -1
- package/lib/Compatibility/legacy-store/constants.js +0 -1
- package/lib/Types/Message.d.ts +2 -0
- package/lib/Utils/messages.js +49 -0
- package/lib/Utils/process-message.js +38 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -243,6 +243,19 @@ A few behaviors that differ from upstream — almost always to your advantage:
|
|
|
243
243
|
`connectTimeoutMs`, `qrTimeout`, `printQRInTerminal`, `ignoreOfflineMessages`,
|
|
244
244
|
`downloadHistory`, `agent`, `fetchAgent`, `mobile`, `mediaCache`,
|
|
245
245
|
`userDevicesCache`, `callOfferCache` and `placeholderResendCache`.
|
|
246
|
+
- **A sender's resend reaches you once, not once per delivery.** When a
|
|
247
|
+
sender's network is bad, their client re-runs its own outbox and sends the
|
|
248
|
+
same message again — same `key.id`, re-encrypted. Upstream Baileys emits a
|
|
249
|
+
`messages.upsert` for each of those deliveries, so an auto-reply bot answers
|
|
250
|
+
the same message two or three times; the engine now recognises the repeat and
|
|
251
|
+
emits one. It is a short-lived, in-memory window (five minutes), so the one
|
|
252
|
+
case still open is a sender whose retry spans a restart of your process. If
|
|
253
|
+
your handler has side effects, keying them on the whole `key` — `remoteJid`,
|
|
254
|
+
`id`, `fromMe` and `participant` — remains the thing that makes it safe. Not
|
|
255
|
+
on `id` alone: the id is the sending client's to choose, so two participants
|
|
256
|
+
of one group can pick the same one, and a handler that keys on it would drop
|
|
257
|
+
the second person's message rather than a duplicate. That is why the engine's
|
|
258
|
+
own check carries the sender too.
|
|
246
259
|
- **`Boom` ships in the box.** baileyrs exports its own
|
|
247
260
|
`@hapi/boom`-compatible `Boom`, so the existing
|
|
248
261
|
`(err as Boom).output.statusCode` pattern works unchanged. If your
|
|
@@ -102,11 +102,18 @@ function tcTokenCodec() {
|
|
|
102
102
|
},
|
|
103
103
|
toLegacy(_key, value) {
|
|
104
104
|
const native = fromJsonBytes(value);
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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<{
|
package/lib/Types/Message.d.ts
CHANGED
|
@@ -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 = {
|
package/lib/Utils/messages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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.
|
|
4
|
+
"version": "0.2.10",
|
|
5
5
|
"description": "A Rust-powered WhatsApp Web library for JavaScript, with a Baileys-compatible API",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"whatsapp",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
84
|
"@hapi/boom": "^9.1.4",
|
|
85
|
-
"@oxidezap/whatsapp-rust-bridge": "0.
|
|
85
|
+
"@oxidezap/whatsapp-rust-bridge": "0.19.0",
|
|
86
86
|
"long": "^5.3.2",
|
|
87
87
|
"pino": "^10.3.1",
|
|
88
88
|
"protobufjs": "^7.6.5"
|