zapo-js 1.8.2 → 1.10.0
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 +14 -13
- package/dist/auth/credentials-flow.js +2 -3
- package/dist/client/WaClient.d.ts +4 -0
- package/dist/client/WaClient.js +5 -1
- package/dist/client/coordinators/WaMessageCoordinator.d.ts +11 -4
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +11 -0
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +15 -5
- package/dist/client/messaging/ignore-key.d.ts +2 -1
- package/dist/client/messaging/ignore-key.js +24 -3
- package/dist/esm/auth/credentials-flow.js +3 -4
- package/dist/esm/client/WaClient.js +5 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -5
- package/dist/esm/client/messaging/ignore-key.js +25 -4
- package/dist/esm/message/encode/content.js +4 -1
- package/dist/esm/store/cache/identity.cache.js +30 -5
- package/dist/esm/store/cache/privacy-token.cache.js +30 -5
- package/dist/esm/store/cache/sender-key.cache.js +38 -7
- package/dist/esm/store/cache/session.cache.js +30 -4
- package/dist/esm/store/createStore.js +39 -4
- package/dist/esm/store/memory/identity.store.js +28 -4
- package/dist/esm/store/memory/privacy-token.store.js +24 -7
- package/dist/esm/store/memory/sender-key.store.js +36 -7
- package/dist/esm/store/memory/session.store.js +41 -7
- package/dist/esm/transport/node/WaMobileTcpSocket.js +69 -9
- package/dist/esm/transport/noise/WaMobileClientPayload.js +27 -7
- package/dist/esm/transport/proxy.js +83 -0
- package/dist/esm/util/collections.js +94 -0
- package/dist/esm/util/index.js +2 -2
- package/dist/message/encode/content.js +4 -1
- package/dist/message/primitives/media-retry.d.ts +11 -0
- package/dist/store/cache/identity.cache.d.ts +6 -4
- package/dist/store/cache/identity.cache.js +30 -5
- package/dist/store/cache/privacy-token.cache.d.ts +6 -4
- package/dist/store/cache/privacy-token.cache.js +30 -5
- package/dist/store/cache/sender-key.cache.d.ts +5 -3
- package/dist/store/cache/sender-key.cache.js +38 -7
- package/dist/store/cache/session.cache.d.ts +6 -3
- package/dist/store/cache/session.cache.js +30 -4
- package/dist/store/createStore.js +39 -4
- package/dist/store/memory/identity.store.d.ts +7 -1
- package/dist/store/memory/identity.store.js +28 -4
- package/dist/store/memory/privacy-token.store.d.ts +7 -1
- package/dist/store/memory/privacy-token.store.js +24 -7
- package/dist/store/memory/sender-key.store.d.ts +12 -2
- package/dist/store/memory/sender-key.store.js +36 -7
- package/dist/store/memory/session.store.d.ts +7 -1
- package/dist/store/memory/session.store.js +41 -7
- package/dist/store/types.d.ts +43 -4
- package/dist/transport/node/WaMobileTcpSocket.d.ts +3 -1
- package/dist/transport/node/WaMobileTcpSocket.js +68 -8
- package/dist/transport/noise/WaMobileClientPayload.d.ts +22 -0
- package/dist/transport/noise/WaMobileClientPayload.js +27 -7
- package/dist/transport/proxy.d.ts +24 -0
- package/dist/transport/proxy.js +85 -0
- package/dist/util/collections.d.ts +46 -0
- package/dist/util/collections.js +98 -0
- package/dist/util/index.d.ts +2 -2
- package/dist/util/index.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,19 +127,20 @@ The core lives at the repo root (`zapo-js`). Optional packages live in
|
|
|
127
127
|
[`packages/`](packages/) and ship under the `@zapo-js/*` scope. Install
|
|
128
128
|
only what you need.
|
|
129
129
|
|
|
130
|
-
| Package | Peer dependency
|
|
131
|
-
| ---------------------------------------------------- |
|
|
132
|
-
| [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3`
|
|
133
|
-
| [`@zapo-js/store-redis`](packages/store-redis) | `ioredis`
|
|
134
|
-
| [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb`
|
|
135
|
-
| [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2`
|
|
136
|
-
| [`@zapo-js/store-postgres`](packages/store-postgres) | `pg`
|
|
137
|
-
| [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg`
|
|
138
|
-
| [`@zapo-js/native`](packages/native) | (none)
|
|
139
|
-
| [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm`
|
|
140
|
-
| [`@zapo-js/
|
|
141
|
-
| [`@zapo-js/
|
|
142
|
-
| [`@zapo-js/
|
|
130
|
+
| Package | Peer dependency | Purpose |
|
|
131
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
132
|
+
| [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
|
|
133
|
+
| [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
|
|
134
|
+
| [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
|
|
135
|
+
| [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
|
|
136
|
+
| [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
|
|
137
|
+
| [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
|
|
138
|
+
| [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
|
|
139
|
+
| [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm-fork` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
|
|
140
|
+
| [`@zapo-js/voip-media`](packages/voip-media) | `libmlow-wasm-fork` (optional, to carry media) + `@roamhq/wrtc` (optional, Node host) | Call media plane with no Node or `zapo-js` dependency, so it runs in the browser: relay transport, STUN, SRTP, RTP/RTCP, MLow codec. |
|
|
141
|
+
| [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
|
|
142
|
+
| [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
|
|
143
|
+
| [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
|
|
143
144
|
|
|
144
145
|
Each package's README has the install + config + integration notes.
|
|
145
146
|
|
|
@@ -113,9 +113,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
|
|
|
113
113
|
assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
|
|
114
114
|
}
|
|
115
115
|
if (effectiveMobileTransport) {
|
|
116
|
-
|
|
117
|
-
throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
|
|
118
|
-
}
|
|
116
|
+
(0, proxy_1.assertTcpProxySupported)(wsProxy);
|
|
119
117
|
if (!loginIdentity) {
|
|
120
118
|
throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
|
|
121
119
|
}
|
|
@@ -138,6 +136,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
|
|
|
138
136
|
return {
|
|
139
137
|
url: effectiveMobileTransport.tcpUrl ?? 'tcp://g.whatsapp.net:443',
|
|
140
138
|
rawWebSocketConstructor: WaMobileTcpSocket_1.WaMobileTcpSocketCtor,
|
|
139
|
+
agent: (0, proxy_1.toProxyAgent)(wsProxy),
|
|
141
140
|
connectTimeoutMs: socketOptions.connectTimeoutMs,
|
|
142
141
|
reconnectIntervalMs: socketOptions.reconnectIntervalMs,
|
|
143
142
|
timeoutIntervalMs: socketOptions.timeoutIntervalMs,
|
|
@@ -132,6 +132,10 @@ declare class WaClientImpl extends EventEmitter {
|
|
|
132
132
|
* arrays are empty. The predicate receives a parsed
|
|
133
133
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
134
134
|
*
|
|
135
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
136
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
137
|
+
* traffic between our own devices, not chat messages.
|
|
138
|
+
*
|
|
135
139
|
* @example
|
|
136
140
|
* ```ts
|
|
137
141
|
* client.ignoreKey({ remoteJid: spammerJid })
|
package/dist/client/WaClient.js
CHANGED
|
@@ -458,6 +458,10 @@ class WaClientImpl extends node_events_1.EventEmitter {
|
|
|
458
458
|
* arrays are empty. The predicate receives a parsed
|
|
459
459
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
460
460
|
*
|
|
461
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
462
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
463
|
+
* traffic between our own devices, not chat messages.
|
|
464
|
+
*
|
|
461
465
|
* @example
|
|
462
466
|
* ```ts
|
|
463
467
|
* client.ignoreKey({ remoteJid: spammerJid })
|
|
@@ -469,7 +473,7 @@ class WaClientImpl extends node_events_1.EventEmitter {
|
|
|
469
473
|
if (typeof input !== 'function') {
|
|
470
474
|
(0, ignore_key_1.validateIgnoreKey)(input);
|
|
471
475
|
}
|
|
472
|
-
const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials()
|
|
476
|
+
const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials());
|
|
473
477
|
return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
|
|
474
478
|
}
|
|
475
479
|
/** Auth client: pairing, credentials, registration state. */
|
|
@@ -384,10 +384,17 @@ export declare class WaMessageCoordinator {
|
|
|
384
384
|
* the sealed request and resolves once the server answers with a
|
|
385
385
|
* `mediaretry` notification, usually within a couple of seconds.
|
|
386
386
|
*
|
|
387
|
-
* On `result: 'success'`
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
387
|
+
* On `result: 'success'` the media key, hashes, and length of the original
|
|
388
|
+
* message normally stay valid, so patch the fresh `directPath` into the
|
|
389
|
+
* message and download again. A second call for a message already in
|
|
390
|
+
* flight joins the first request instead of sending another receipt.
|
|
391
|
+
*
|
|
392
|
+
* A `success` is not a guarantee that the original key still opens the
|
|
393
|
+
* blob. Some primaries re-encrypt the file on every re-upload, and the
|
|
394
|
+
* re-served bytes then fail `fileEncSha256` and make `downloadBytes()`
|
|
395
|
+
* throw a MAC mismatch. Nothing in the round-trip carries key material for
|
|
396
|
+
* the new ciphertext, so the message cannot be recovered through this API
|
|
397
|
+
* and requesting another reupload will not help.
|
|
391
398
|
*
|
|
392
399
|
* The other three `result` values are answers too, not thrown errors:
|
|
393
400
|
* `not_found` means the sender no longer holds the file and nothing can
|
|
@@ -7,6 +7,7 @@ import type { WaGroupEvent, WaOutgoingMessageEvent, WaSendMessageOptions, WaSign
|
|
|
7
7
|
import type { Logger } from '../../infra/log/types';
|
|
8
8
|
import { type WaSendContextInfo } from '../../message/context-info';
|
|
9
9
|
import { type IcdcMeta } from '../../message/crypto/icdc';
|
|
10
|
+
import { type WaOutboundMessageAttrs } from '../../message/encode/content';
|
|
10
11
|
import type { WaEncryptedMessageInput, WaMessageBuildResult, WaMessagePublishOptions, WaMessagePublishResult, WaSendMessageContent, WaSendReceiptInput } from '../../message/types';
|
|
11
12
|
import type { WaMessageClient } from '../../message/WaMessageClient';
|
|
12
13
|
import { type Proto } from '../../proto';
|
|
@@ -96,6 +97,16 @@ export interface WaGroupHistoryAudience {
|
|
|
96
97
|
* while nobody received anything.
|
|
97
98
|
*/
|
|
98
99
|
export declare function restrictGroupHistoryTargets(message: Proto.IMessage, participantUserJids: readonly string[], senderJid: string, onDropped?: (droppedJids: readonly string[], totalExpected: number) => void): readonly string[];
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
102
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
103
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
104
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
105
|
+
*/
|
|
106
|
+
export declare function resolveOutboundStanzaShape(attrs: Pick<WaOutboundMessageAttrs, 'buttonAddonKind' | 'typeAttr' | 'mediatype'>, isGroup: boolean): {
|
|
107
|
+
readonly type: string;
|
|
108
|
+
readonly mediatype: string | undefined;
|
|
109
|
+
};
|
|
99
110
|
export declare class WaMessageDispatchCoordinator {
|
|
100
111
|
private readonly deps;
|
|
101
112
|
private readonly mobileMessageIdFormat;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WaMessageDispatchCoordinator = void 0;
|
|
4
4
|
exports.restrictGroupHistoryTargets = restrictGroupHistoryTargets;
|
|
5
|
+
exports.resolveOutboundStanzaShape = resolveOutboundStanzaShape;
|
|
5
6
|
const _crypto_1 = require("../../crypto");
|
|
6
7
|
const primitives_1 = require("../../crypto/core/primitives");
|
|
7
8
|
const PromiseDedup_1 = require("../../infra/perf/PromiseDedup");
|
|
@@ -73,6 +74,19 @@ function restrictGroupHistoryTargets(message, participantUserJids, senderJid, on
|
|
|
73
74
|
}
|
|
74
75
|
return targets;
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
79
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
80
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
81
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
82
|
+
*/
|
|
83
|
+
function resolveOutboundStanzaShape(attrs, isGroup) {
|
|
84
|
+
const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
|
|
85
|
+
if (flattensForAddon) {
|
|
86
|
+
return { type: 'text', mediatype: undefined };
|
|
87
|
+
}
|
|
88
|
+
return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
|
|
89
|
+
}
|
|
76
90
|
class WaMessageDispatchCoordinator {
|
|
77
91
|
constructor(options) {
|
|
78
92
|
this.icdcDedup = new PromiseDedup_1.PromiseDedup();
|
|
@@ -403,12 +417,8 @@ class WaMessageDispatchCoordinator {
|
|
|
403
417
|
const outboundAttrs = (0, content_1.resolveOutboundMessageAttrs)(messageWithIcdc);
|
|
404
418
|
const buttonAddonKind = outboundAttrs.buttonAddonKind;
|
|
405
419
|
const buttonAddonNode = buttonAddonKind ? (0, message_1.buildButtonAddonNode)(buttonAddonKind) : undefined;
|
|
406
|
-
|
|
407
|
-
// omit enc.mediatype; sending type=media + mediatype=list/button alongside the
|
|
408
|
-
// companion is rejected by the server as SMAX_INVALID (479).
|
|
409
|
-
const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
|
|
420
|
+
const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
|
|
410
421
|
const edit = outboundAttrs.edit ?? undefined;
|
|
411
|
-
const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
|
|
412
422
|
const metaAttrs = outboundAttrs.metaAttrs;
|
|
413
423
|
const metaNode = metaAttrs ? (0, message_1.buildMetaNode)(metaAttrs) : undefined;
|
|
414
424
|
const customNodes = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WaAuthCredentials } from '../../auth/types';
|
|
1
2
|
import type { WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIncomingStanzaFilter } from '../types';
|
|
2
3
|
import type { BinaryNode } from '../../transport/types';
|
|
3
4
|
export declare function validateIgnoreKey(d: WaIgnoreKey): void;
|
|
@@ -8,4 +9,4 @@ export declare function validateIgnoreKey(d: WaIgnoreKey): void;
|
|
|
8
9
|
export declare function extractIgnoreKeyContext(node: BinaryNode, meJid: string | null | undefined): WaIgnoreKeyContext | null;
|
|
9
10
|
/** Pure matcher. Exported for direct testing without a coordinator. */
|
|
10
11
|
export declare function matchesIgnoreKey(node: BinaryNode, d: WaIgnoreKey, meJid: string | null | undefined): boolean;
|
|
11
|
-
export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate,
|
|
12
|
+
export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMe: () => Pick<WaAuthCredentials, 'meJid' | 'meLid'> | null | undefined): WaIncomingStanzaFilter;
|
|
@@ -123,12 +123,33 @@ function matchesIgnoreKey(node, d, meJid) {
|
|
|
123
123
|
return false;
|
|
124
124
|
return true;
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
/**
|
|
127
|
+
* A `category='peer'` message from our own account is traffic between our own
|
|
128
|
+
* devices (app-state key share, history sync, PDO responses), not a chat
|
|
129
|
+
* message, so it has no key to ignore by. Dropping it starves the protocol:
|
|
130
|
+
* app-state collections stay blocked on a key whose share never gets through.
|
|
131
|
+
* The own-account check keeps a foreign sender from dodging a filter by
|
|
132
|
+
* stamping the attribute.
|
|
133
|
+
*/
|
|
134
|
+
function isOwnPeerMessage(node, me) {
|
|
135
|
+
if (node.tag !== message_1.WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const from = node.attrs.from;
|
|
139
|
+
return !!from && tryParseJid(from) !== null && (0, jid_1.isOwnAccountJid)(from, me.meJid, me.meLid);
|
|
140
|
+
}
|
|
141
|
+
function createIgnoreKeyFilter(input, getMe) {
|
|
127
142
|
if (typeof input === 'function') {
|
|
128
143
|
return (node) => {
|
|
129
|
-
const
|
|
144
|
+
const me = getMe();
|
|
145
|
+
if (isOwnPeerMessage(node, me))
|
|
146
|
+
return false;
|
|
147
|
+
const ctx = extractIgnoreKeyContext(node, me?.meJid);
|
|
130
148
|
return ctx !== null && input(ctx);
|
|
131
149
|
};
|
|
132
150
|
}
|
|
133
|
-
return (node) =>
|
|
151
|
+
return (node) => {
|
|
152
|
+
const me = getMe();
|
|
153
|
+
return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
|
|
154
|
+
};
|
|
134
155
|
}
|
|
@@ -5,7 +5,7 @@ import { getLoginIdentity } from '../protocol/jid.js';
|
|
|
5
5
|
import { createAndStoreInitialKeys } from '../signal/index.js';
|
|
6
6
|
import { WaMobileTcpSocketCtor } from '../transport/node/WaMobileTcpSocket.js';
|
|
7
7
|
import { buildMobileLoginPayload } from '../transport/noise/WaMobileClientPayload.js';
|
|
8
|
-
import { toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
|
|
8
|
+
import { assertTcpProxySupported, toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
|
|
9
9
|
import { parseOptionalInt, toError } from '../util/primitives.js';
|
|
10
10
|
export async function loadOrCreateCredentials(args) {
|
|
11
11
|
args.logger.trace('auth credentials loadOrCreate start');
|
|
@@ -108,9 +108,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
108
108
|
assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
|
|
109
109
|
}
|
|
110
110
|
if (effectiveMobileTransport) {
|
|
111
|
-
|
|
112
|
-
throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
|
|
113
|
-
}
|
|
111
|
+
assertTcpProxySupported(wsProxy);
|
|
114
112
|
if (!loginIdentity) {
|
|
115
113
|
throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
|
|
116
114
|
}
|
|
@@ -133,6 +131,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
133
131
|
return {
|
|
134
132
|
url: effectiveMobileTransport.tcpUrl ?? 'tcp://g.whatsapp.net:443',
|
|
135
133
|
rawWebSocketConstructor: WaMobileTcpSocketCtor,
|
|
134
|
+
agent: toProxyAgent(wsProxy),
|
|
136
135
|
connectTimeoutMs: socketOptions.connectTimeoutMs,
|
|
137
136
|
reconnectIntervalMs: socketOptions.reconnectIntervalMs,
|
|
138
137
|
timeoutIntervalMs: socketOptions.timeoutIntervalMs,
|
|
@@ -455,6 +455,10 @@ class WaClientImpl extends EventEmitter {
|
|
|
455
455
|
* arrays are empty. The predicate receives a parsed
|
|
456
456
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
457
457
|
*
|
|
458
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
459
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
460
|
+
* traffic between our own devices, not chat messages.
|
|
461
|
+
*
|
|
458
462
|
* @example
|
|
459
463
|
* ```ts
|
|
460
464
|
* client.ignoreKey({ remoteJid: spammerJid })
|
|
@@ -466,7 +470,7 @@ class WaClientImpl extends EventEmitter {
|
|
|
466
470
|
if (typeof input !== 'function') {
|
|
467
471
|
validateIgnoreKey(input);
|
|
468
472
|
}
|
|
469
|
-
const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials()
|
|
473
|
+
const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials());
|
|
470
474
|
return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
|
|
471
475
|
}
|
|
472
476
|
/** Auth client: pairing, credentials, registration state. */
|
|
@@ -69,6 +69,19 @@ export function restrictGroupHistoryTargets(message, participantUserJids, sender
|
|
|
69
69
|
}
|
|
70
70
|
return targets;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
74
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
75
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
76
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveOutboundStanzaShape(attrs, isGroup) {
|
|
79
|
+
const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
|
|
80
|
+
if (flattensForAddon) {
|
|
81
|
+
return { type: 'text', mediatype: undefined };
|
|
82
|
+
}
|
|
83
|
+
return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
|
|
84
|
+
}
|
|
72
85
|
export class WaMessageDispatchCoordinator {
|
|
73
86
|
constructor(options) {
|
|
74
87
|
this.icdcDedup = new PromiseDedup();
|
|
@@ -399,12 +412,8 @@ export class WaMessageDispatchCoordinator {
|
|
|
399
412
|
const outboundAttrs = resolveOutboundMessageAttrs(messageWithIcdc);
|
|
400
413
|
const buttonAddonKind = outboundAttrs.buttonAddonKind;
|
|
401
414
|
const buttonAddonNode = buttonAddonKind ? buildButtonAddonNode(buttonAddonKind) : undefined;
|
|
402
|
-
|
|
403
|
-
// omit enc.mediatype; sending type=media + mediatype=list/button alongside the
|
|
404
|
-
// companion is rejected by the server as SMAX_INVALID (479).
|
|
405
|
-
const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
|
|
415
|
+
const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
|
|
406
416
|
const edit = outboundAttrs.edit ?? undefined;
|
|
407
|
-
const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
|
|
408
417
|
const metaAttrs = outboundAttrs.metaAttrs;
|
|
409
418
|
const metaNode = metaAttrs ? buildMetaNode(metaAttrs) : undefined;
|
|
410
419
|
const customNodes = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseJidFull } from '../../protocol/jid.js';
|
|
1
|
+
import { isOwnAccountJid, parseJidFull } from '../../protocol/jid.js';
|
|
2
2
|
import { WA_MESSAGE_TAGS } from '../../protocol/message.js';
|
|
3
3
|
import { WA_NODE_TAGS } from '../../protocol/nodes.js';
|
|
4
4
|
const TAG_TO_KIND = {
|
|
@@ -117,12 +117,33 @@ export function matchesIgnoreKey(node, d, meJid) {
|
|
|
117
117
|
return false;
|
|
118
118
|
return true;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* A `category='peer'` message from our own account is traffic between our own
|
|
122
|
+
* devices (app-state key share, history sync, PDO responses), not a chat
|
|
123
|
+
* message, so it has no key to ignore by. Dropping it starves the protocol:
|
|
124
|
+
* app-state collections stay blocked on a key whose share never gets through.
|
|
125
|
+
* The own-account check keeps a foreign sender from dodging a filter by
|
|
126
|
+
* stamping the attribute.
|
|
127
|
+
*/
|
|
128
|
+
function isOwnPeerMessage(node, me) {
|
|
129
|
+
if (node.tag !== WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const from = node.attrs.from;
|
|
133
|
+
return !!from && tryParseJid(from) !== null && isOwnAccountJid(from, me.meJid, me.meLid);
|
|
134
|
+
}
|
|
135
|
+
export function createIgnoreKeyFilter(input, getMe) {
|
|
121
136
|
if (typeof input === 'function') {
|
|
122
137
|
return (node) => {
|
|
123
|
-
const
|
|
138
|
+
const me = getMe();
|
|
139
|
+
if (isOwnPeerMessage(node, me))
|
|
140
|
+
return false;
|
|
141
|
+
const ctx = extractIgnoreKeyContext(node, me?.meJid);
|
|
124
142
|
return ctx !== null && input(ctx);
|
|
125
143
|
};
|
|
126
144
|
}
|
|
127
|
-
return (node) =>
|
|
145
|
+
return (node) => {
|
|
146
|
+
const me = getMe();
|
|
147
|
+
return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
|
|
148
|
+
};
|
|
128
149
|
}
|
|
@@ -154,7 +154,8 @@ export function unwrapMessage(message) {
|
|
|
154
154
|
msg.viewOnceMessageV2?.message ??
|
|
155
155
|
msg.documentWithCaptionMessage?.message ??
|
|
156
156
|
msg.groupStatusMessage?.message ??
|
|
157
|
-
msg.groupStatusMessageV2?.message
|
|
157
|
+
msg.groupStatusMessageV2?.message ??
|
|
158
|
+
msg.botForwardedMessage?.message;
|
|
158
159
|
if (!inner)
|
|
159
160
|
return msg;
|
|
160
161
|
msg = inner;
|
|
@@ -202,6 +203,8 @@ function resolveMessageTypeAttrFrom(msg) {
|
|
|
202
203
|
msg.pollResultSnapshotMessageV3 ||
|
|
203
204
|
msg.templateButtonReplyMessage ||
|
|
204
205
|
msg.messageHistoryNotice ||
|
|
206
|
+
msg.richResponseMessage ||
|
|
207
|
+
msg.botForwardedMessage ||
|
|
205
208
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
206
209
|
proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT ||
|
|
207
210
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
import { WaIdentityMemoryStore } from '../memory/identity.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdleIdentityMemoryStore extends WaIdentityMemoryStore {
|
|
5
|
+
constructor(maxRemoteIdentities, expiry) {
|
|
6
|
+
super({ maxRemoteIdentities });
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.remoteIdentities, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(key) {
|
|
13
|
+
this.idle.touch(key);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(key) {
|
|
16
|
+
this.idle.delete(key);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through / write-through in-process cache for a persistent remote
|
|
4
|
-
* identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded
|
|
5
|
-
*
|
|
24
|
+
* identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded L1.
|
|
25
|
+
* Remote identities are read alongside sessions on the send path (the
|
|
6
26
|
* identity-mismatch guard), so caching them complements
|
|
7
|
-
* {@link withSessionCache}
|
|
27
|
+
* {@link withSessionCache}, which also describes the eviction with and
|
|
28
|
+
* without `expiry`.
|
|
8
29
|
*
|
|
9
30
|
* The identity store has no per-key delete: identities are overwritten on
|
|
10
31
|
* re-establishment, so a peer's key change propagates through the
|
|
11
32
|
* write-through `setRemoteIdentity`. See {@link withSessionCache} for the
|
|
12
33
|
* shared coherence model and the single-writer-per-session assumption.
|
|
13
34
|
*/
|
|
14
|
-
export function withIdentityCache(backend, maxEntries) {
|
|
15
|
-
const l1 =
|
|
35
|
+
export function withIdentityCache(backend, maxEntries, expiry) {
|
|
36
|
+
const l1 = expiry
|
|
37
|
+
? new IdleIdentityMemoryStore(maxEntries, expiry)
|
|
38
|
+
: new WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
|
|
16
39
|
let generation = 0;
|
|
17
40
|
return {
|
|
18
41
|
getRemoteIdentity: async (address) => {
|
|
@@ -68,6 +91,8 @@ export function withIdentityCache(backend, maxEntries) {
|
|
|
68
91
|
await l1.clear();
|
|
69
92
|
},
|
|
70
93
|
destroy: async () => {
|
|
94
|
+
if (l1 instanceof IdleIdentityMemoryStore)
|
|
95
|
+
l1.detach();
|
|
71
96
|
await l1.clear();
|
|
72
97
|
await backend.destroy?.();
|
|
73
98
|
}
|
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
import { WaPrivacyTokenMemoryStore } from '../memory/privacy-token.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdlePrivacyTokenMemoryStore extends WaPrivacyTokenMemoryStore {
|
|
5
|
+
constructor(maxEntries, expiry) {
|
|
6
|
+
super(maxEntries);
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.records, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(jid) {
|
|
13
|
+
this.idle.touch(jid);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(jid) {
|
|
16
|
+
this.idle.delete(jid);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through cache for a persistent privacy-token backend. Reuses
|
|
4
|
-
* {@link WaPrivacyTokenMemoryStore} as the bounded
|
|
24
|
+
* {@link WaPrivacyTokenMemoryStore} as the bounded L1.
|
|
5
25
|
*
|
|
6
26
|
* Unlike the signal caches this is **invalidate-on-write, not
|
|
7
27
|
* write-through**: `upsert` merges partial fields into the existing row on
|
|
8
28
|
* the backend, so caching the partial incoming record would diverge from the
|
|
9
29
|
* backend's merged result. Instead each upsert drops the L1 entry and the
|
|
10
30
|
* next read re-populates from the merged backend truth. See
|
|
11
|
-
* {@link withSessionCache} for the shared coherence model
|
|
12
|
-
* single-writer-per-session assumption
|
|
31
|
+
* {@link withSessionCache} for the shared coherence model, the
|
|
32
|
+
* single-writer-per-session assumption and the eviction with and without
|
|
33
|
+
* `expiry`.
|
|
13
34
|
*/
|
|
14
|
-
export function withPrivacyTokenCache(backend, maxEntries) {
|
|
15
|
-
const l1 =
|
|
35
|
+
export function withPrivacyTokenCache(backend, maxEntries, expiry) {
|
|
36
|
+
const l1 = expiry
|
|
37
|
+
? new IdlePrivacyTokenMemoryStore(maxEntries, expiry)
|
|
38
|
+
: new WaPrivacyTokenMemoryStore(maxEntries);
|
|
16
39
|
let generation = 0;
|
|
17
40
|
return {
|
|
18
41
|
upsert: async (record) => {
|
|
@@ -50,6 +73,8 @@ export function withPrivacyTokenCache(backend, maxEntries) {
|
|
|
50
73
|
await l1.clear();
|
|
51
74
|
},
|
|
52
75
|
destroy: async () => {
|
|
76
|
+
if (l1 instanceof IdlePrivacyTokenMemoryStore)
|
|
77
|
+
l1.detach();
|
|
53
78
|
await l1.clear();
|
|
54
79
|
await backend.destroy?.();
|
|
55
80
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep both maps in access order, so the caps evict LRU entries. */
|
|
4
|
+
class IdleSenderKeyMemoryStore extends SenderKeyMemoryStore {
|
|
5
|
+
constructor(maxEntries, expiry) {
|
|
6
|
+
super({ maxSenderKeys: maxEntries, maxSenderDistributions: maxEntries });
|
|
7
|
+
this.idleSenderKeys = new IdleExpiryIndex(this.senderKeys, expiry);
|
|
8
|
+
this.idleDistributions = new IdleExpiryIndex(this.senderDistributions, expiry);
|
|
9
|
+
}
|
|
10
|
+
detach() {
|
|
11
|
+
this.idleSenderKeys.detach();
|
|
12
|
+
this.idleDistributions.detach();
|
|
13
|
+
}
|
|
14
|
+
onEntryAccess(entries, key) {
|
|
15
|
+
this.indexFor(entries).touch(key);
|
|
16
|
+
}
|
|
17
|
+
onEntryRemove(entries, key) {
|
|
18
|
+
this.indexFor(entries).delete(key);
|
|
19
|
+
}
|
|
20
|
+
onEntriesClear() {
|
|
21
|
+
this.idleSenderKeys.clear();
|
|
22
|
+
this.idleDistributions.clear();
|
|
23
|
+
}
|
|
24
|
+
indexFor(entries) {
|
|
25
|
+
return entries === this.senderKeys ? this.idleSenderKeys : this.idleDistributions;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* Read-through / write-through in-process cache for a persistent sender-key
|
|
4
|
-
* backend. Reuses {@link SenderKeyMemoryStore} as the bounded
|
|
30
|
+
* backend. Reuses {@link SenderKeyMemoryStore} as the bounded L1 so the
|
|
5
31
|
* per-(group, sender) lookups repeated across a group fan-out skip the
|
|
6
32
|
* backend round-trip.
|
|
7
33
|
*
|
|
@@ -12,13 +38,16 @@ import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
|
|
|
12
38
|
* (`deleteDeviceSenderKey`/`markForgetSenderKey`) run on the backend for the
|
|
13
39
|
* authoritative count and then invalidate the matching L1 entries via the
|
|
14
40
|
* memory store's own sweep. See {@link withSessionCache} for the shared
|
|
15
|
-
* coherence model
|
|
41
|
+
* coherence model, the single-writer-per-session assumption and the eviction
|
|
42
|
+
* with and without `expiry`.
|
|
16
43
|
*/
|
|
17
|
-
export function withSenderKeyCache(backend, maxEntries) {
|
|
18
|
-
const l1 =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
44
|
+
export function withSenderKeyCache(backend, maxEntries, expiry) {
|
|
45
|
+
const l1 = expiry
|
|
46
|
+
? new IdleSenderKeyMemoryStore(maxEntries, expiry)
|
|
47
|
+
: new SenderKeyMemoryStore({
|
|
48
|
+
maxSenderKeys: maxEntries,
|
|
49
|
+
maxSenderDistributions: maxEntries
|
|
50
|
+
});
|
|
22
51
|
let generation = 0;
|
|
23
52
|
return {
|
|
24
53
|
upsertSenderKey: async (record) => {
|
|
@@ -94,6 +123,8 @@ export function withSenderKeyCache(backend, maxEntries) {
|
|
|
94
123
|
await l1.clear();
|
|
95
124
|
},
|
|
96
125
|
destroy: async () => {
|
|
126
|
+
if (l1 instanceof IdleSenderKeyMemoryStore)
|
|
127
|
+
l1.detach();
|
|
97
128
|
await l1.clear();
|
|
98
129
|
await backend.destroy?.();
|
|
99
130
|
}
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { WaSessionMemoryStore } from '../memory/session.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdleSessionMemoryStore extends WaSessionMemoryStore {
|
|
5
|
+
constructor(maxSessions, expiry) {
|
|
6
|
+
super({ maxSessions });
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.signalSessions, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(key) {
|
|
13
|
+
this.idle.touch(key);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(key) {
|
|
16
|
+
this.idle.delete(key);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through / write-through in-process cache for a persistent session
|
|
4
|
-
* backend. Reuses {@link WaSessionMemoryStore} as the bounded
|
|
24
|
+
* backend. Reuses {@link WaSessionMemoryStore} as the bounded L1 so that
|
|
5
25
|
* repeated reads of the same peer on the send/recv path skip the backend
|
|
6
|
-
* round-trip.
|
|
26
|
+
* round-trip. Without `expiry` the cap evicts the least recently written
|
|
27
|
+
* entry; with it, the least recently used, and entries idle past the TTL
|
|
28
|
+
* leave the L1 (never the backend) and are re-read on next use.
|
|
7
29
|
*
|
|
8
30
|
* Coherence model (single process):
|
|
9
31
|
* - every mutation (set/delete/clear) writes the backend then the L1 in
|
|
@@ -19,8 +41,10 @@ import { WaSessionMemoryStore } from '../memory/session.store.js';
|
|
|
19
41
|
* session with the cache enabled - there is no cross-process invalidation
|
|
20
42
|
* channel, so another process's writes would leave this L1 stale.
|
|
21
43
|
*/
|
|
22
|
-
export function withSessionCache(backend, maxEntries) {
|
|
23
|
-
const l1 =
|
|
44
|
+
export function withSessionCache(backend, maxEntries, expiry) {
|
|
45
|
+
const l1 = expiry
|
|
46
|
+
? new IdleSessionMemoryStore(maxEntries, expiry)
|
|
47
|
+
: new WaSessionMemoryStore({ maxSessions: maxEntries });
|
|
24
48
|
let generation = 0;
|
|
25
49
|
return {
|
|
26
50
|
hasSession: async (address) => {
|
|
@@ -85,6 +109,8 @@ export function withSessionCache(backend, maxEntries) {
|
|
|
85
109
|
await l1.clear();
|
|
86
110
|
},
|
|
87
111
|
destroy: async () => {
|
|
112
|
+
if (l1 instanceof IdleSessionMemoryStore)
|
|
113
|
+
l1.detach();
|
|
88
114
|
await l1.clear();
|
|
89
115
|
await backend.destroy?.();
|
|
90
116
|
}
|