@zapo-js/fake-server 1.0.0 → 1.1.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 +93 -5
- package/dist/api/FakePeer.js +7 -1
- package/dist/api/FakeServerSession.d.ts +67 -0
- package/dist/api/FakeServerSession.js +293 -0
- package/dist/api/FakeWaServer.d.ts +74 -38
- package/dist/api/FakeWaServer.js +125 -276
- package/dist/api/iq-handlers.d.ts +1 -0
- package/dist/api/iq-handlers.js +23 -0
- package/dist/bin.js +69 -21
- package/dist/esm/api/FakePeer.js +7 -1
- package/dist/esm/api/FakeServerSession.js +287 -0
- package/dist/esm/api/FakeWaServer.js +123 -275
- package/dist/esm/api/iq-handlers.js +23 -0
- package/dist/esm/bin.js +67 -19
- package/dist/esm/index.js +1 -1
- package/dist/esm/infra/WaFakeConnectionPipeline.js +21 -9
- package/dist/esm/protocol/auth/cert-chain.js +13 -2
- package/dist/esm/protocol/iq/router.js +8 -2
- package/dist/esm/transport/util.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/infra/WaFakeConnectionPipeline.d.ts +10 -2
- package/dist/infra/WaFakeConnectionPipeline.js +21 -9
- package/dist/protocol/auth/cert-chain.d.ts +4 -0
- package/dist/protocol/auth/cert-chain.js +13 -2
- package/dist/protocol/iq/router.d.ts +7 -1
- package/dist/protocol/iq/router.js +8 -2
- package/dist/transport/util.d.ts +1 -1
- package/dist/transport/util.js +2 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
In-process fake WhatsApp Web server that drives the real `zapo-js` `WaClient` end-to-end - full Noise XX/IK handshake, QR pairing, Signal Protocol (X3DH + Double Ratchet), SenderKey for groups, media upload/download over self-signed HTTPS, app-state sync - all without touching WhatsApp servers.
|
|
4
4
|
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @zapo-js/fake-server zapo-js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`zapo-js` is a peer dependency - the fake server reuses the core's binary codec, crypto, and protos. Everything below works from any project; nothing requires this monorepo.
|
|
12
|
+
|
|
5
13
|
## Quick start
|
|
6
14
|
|
|
7
15
|
```ts
|
|
@@ -50,7 +58,7 @@ src/
|
|
|
50
58
|
│ ├── fake-media-store.ts # In-memory media blob store
|
|
51
59
|
│ └── fake-app-state-collection.ts # App-state patch/snapshot provider
|
|
52
60
|
├── transport/ # Re-exports from zapo-js (codec, crypto, protos)
|
|
53
|
-
└── __tests__/ # Cross-check test suite
|
|
61
|
+
└── __tests__/ # Cross-check test suite
|
|
54
62
|
└── helpers/ # Shared test utilities (zapo-client factory)
|
|
55
63
|
|
|
56
64
|
bench/
|
|
@@ -63,14 +71,63 @@ bench/
|
|
|
63
71
|
|
|
64
72
|
### FakeWaServer
|
|
65
73
|
|
|
66
|
-
Central facade. Manages the WS listener, noise handshake, IQ router, and all state registries
|
|
74
|
+
Central facade. Manages the WS listener, noise handshake, IQ router, and all state registries.
|
|
75
|
+
|
|
76
|
+
Constructor options (`FakeWaServer.start({...})` / `new FakeWaServer({...})`):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const server = await FakeWaServer.start({
|
|
80
|
+
host: '127.0.0.1', // default
|
|
81
|
+
port: 5222, // default: random free port
|
|
82
|
+
path: '/ws/chat', // default
|
|
83
|
+
successNodeAttributes: {
|
|
84
|
+
// stamped on every post-handshake <success/>
|
|
85
|
+
lid: '5511999@lid',
|
|
86
|
+
displayName: 'Fake Display'
|
|
87
|
+
},
|
|
88
|
+
defaultIqHandlers: false // start with an empty IQ router (default: true)
|
|
89
|
+
})
|
|
90
|
+
```
|
|
67
91
|
|
|
68
92
|
- **Peer registry** (`peerRegistry`): maps device JIDs → `FakePeer` instances. The global `usync` + `prekey-fetch` IQ handlers consult this.
|
|
93
|
+
- **Sessions**: state is grouped per session (see below). A single-client server uses one default session, and the `server.registries` / `server.expectIq(...)` / `server.preKeyDispenser` surface delegates straight to it.
|
|
69
94
|
- **Group registry** (`groupRegistry`): maps group JIDs → group metadata + participants. The `w:g2` handler serves it.
|
|
70
|
-
- **IQ router** (`WaFakeIqRouter`): first-match-wins stanza dispatcher with `{ xmlns, type, childTag }` matchers. ~
|
|
95
|
+
- **IQ router** (`WaFakeIqRouter`): first-match-wins stanza dispatcher with `{ xmlns, type, childTag }` matchers. ~27 global handlers registered in the constructor cover every IQ the lib emits during normal operation (disable them with `defaultIqHandlers: false`). `registerIqHandler(matcher, respond)` registers at high priority and shadows the defaults; a responder that returns `null` falls through to the next matching handler, so you can observe an IQ (capture, assert) and still let the default answer it.
|
|
71
96
|
- **Prekey dispenser**: hands out unique one-time prekeys from the lib's upload to FakePeers. Resets on each forced refill (`triggerPreKeyUpload({ force: true })`).
|
|
72
97
|
- **Listener fan-outs**: `onOutboundGroupOp`, `onOutboundPrivacySet`, `onOutboundBlocklistChange`, `onOutboundProfilePictureSet`, `onOutboundStatusSet`, `onLogout`, `onOutboundPrivacyTokenIssue`, `onOutboundDirtyBitsClear`.
|
|
73
98
|
|
|
99
|
+
### Sessions (multiple clients, one server)
|
|
100
|
+
|
|
101
|
+
By default every connection shares one session, so a single `WaClient` sees the whole `FakeWaServer` surface. To run **several clients against one server without them sharing any state**, pass a `sessionKey` resolver: each authenticated connection is bound to an isolated session keyed by the returned id. Sessions never share peers, groups, prekeys, app-state, or captured stanzas.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const server = await FakeWaServer.start({
|
|
105
|
+
// Key each connection by its login username; pre-login (registration)
|
|
106
|
+
// connections fall under one transient bucket.
|
|
107
|
+
sessionKey: ({ clientPayload }) =>
|
|
108
|
+
clientPayload.kind === 'login' ? String(clientPayload.username) : 'pending'
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// After a client authenticates, reach its isolated state through the pipeline:
|
|
112
|
+
const session = server.sessionFor(pipeline) // FakeServerSession
|
|
113
|
+
await session.expectIq({ xmlns: 'passive', type: 'set' })
|
|
114
|
+
session.registries.peerRegistry // this client's peers only
|
|
115
|
+
session.preKeyDispenser.capturedPreKeyBundleSnapshot() // this client's prekeys
|
|
116
|
+
|
|
117
|
+
// Or address a session by id directly:
|
|
118
|
+
const alice = server.session('5511111111111')
|
|
119
|
+
|
|
120
|
+
// Peer creation routes to the connection's session automatically:
|
|
121
|
+
const peer = await server.createFakePeer({ jid: peerJid }, pipeline)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Notes:
|
|
125
|
+
|
|
126
|
+
- The resolver runs once per connection, right after authentication (so `clientPayload` is available). Connections that resolve to the same id reuse one session.
|
|
127
|
+
- `server.registerIqHandler(...)` applies to every session (present and future); `session.registerIqHandler(...)` scopes to one session.
|
|
128
|
+
- The media HTTPS store is process-global and shared across sessions; isolation covers peers/groups/prekeys/app-state/captures, not uploaded media blobs.
|
|
129
|
+
- QR pairing writes to the connection's session too, so pair clients **sequentially** when keying registration connections into a shared bucket.
|
|
130
|
+
|
|
74
131
|
### FakePeer
|
|
75
132
|
|
|
76
133
|
Simulated WhatsApp peer with real Signal Protocol crypto:
|
|
@@ -101,8 +158,10 @@ Every outbound IQ the lib sends during normal operation is handled:
|
|
|
101
158
|
| `w:p` / `urn:xmpp:ping` get | `whatsapp-ping` / `xmpp-ping` | Ack |
|
|
102
159
|
| `encrypt` set | `prekey-upload` | Captures bundle, resets dispenser |
|
|
103
160
|
| `encrypt` get `<digest>` | `signal-digest` | Returns 404 → forces upload |
|
|
161
|
+
| `encrypt` get `<count>` | `prekey-count` | Serves remaining dispenser prekey count |
|
|
104
162
|
| `encrypt` set `<rotate>` | `signed-prekey-rotate` | Ack |
|
|
105
163
|
| `encrypt` get `<key>` | `prekey-fetch` | Serves peer bundles from registry |
|
|
164
|
+
| `passive` set | `passive-mode` | Ack |
|
|
106
165
|
| `usync` get | `usync` | Resolves device IDs from registry |
|
|
107
166
|
| `w:m` set `<media_conn>` | `media-conn` | Points lib at fake HTTPS server |
|
|
108
167
|
| `w:sync:app:state` set | `app-state-sync` | Serves patches/snapshots from providers |
|
|
@@ -119,6 +178,16 @@ Every outbound IQ the lib sends during normal operation is handled:
|
|
|
119
178
|
| `newsletter` get `<my_addons>` | `newsletter-my-addons` | Ack |
|
|
120
179
|
| `urn:xmpp:whatsapp:dirty` set | `dirty-bits-clear` | Captures cleared bits |
|
|
121
180
|
|
|
181
|
+
## Using with non-zapo-js clients
|
|
182
|
+
|
|
183
|
+
The fake server speaks the real wire protocol, so other WhatsApp Web libraries (Baileys forks, whatsmeow, ...) can connect to it too. The recipe:
|
|
184
|
+
|
|
185
|
+
1. **Point the client's WebSocket URL at `server.url`** (the upgrade path is `/ws/chat`, same as production).
|
|
186
|
+
2. **Trust the fake root CA.** The server signs its Noise cert chain with a per-instance random root; clients that verify the cert signature against WhatsApp's pinned production key must be told to trust `server.noiseRootCa.publicKey` instead (exposed via the CLI's `--json` as `noiseRootCa.publicKeyHex`). The chain carries a valid `notBefore`/`notAfter` window, so clients that enforce validity work out of the box.
|
|
187
|
+
3. **Post-connect IQs are answered by default**: the `passive` set IQ and the `encrypt` get `<count>` prekey-count query, which Baileys-family and whatsmeow clients block on before reporting the connection as open.
|
|
188
|
+
4. **Offline drain bulletin**: after every login the server sends `<ib><offline count="0"/></ib>`, which Baileys-family clients require before flushing their buffered events.
|
|
189
|
+
5. **QR pairing**: in-process, use `server.runPairing(...)` with the client's `advSecretKey` + identity public key; from the standalone CLI, use `--pair <device-jid>` and paste the QR payload the client displays when prompted.
|
|
190
|
+
|
|
122
191
|
## Benchmarking
|
|
123
192
|
|
|
124
193
|
```bash
|
|
@@ -161,16 +230,35 @@ node --expose-gc --import tsx packages/fake-server/bench/messaging.bench.ts --cp
|
|
|
161
230
|
|
|
162
231
|
## CLI
|
|
163
232
|
|
|
233
|
+
The package ships a `fake-wa-server` bin, so the standalone server works from any project (or no project at all):
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
# One-off via npx
|
|
237
|
+
npx @zapo-js/fake-server --port 5222 --peer 5511888@s.whatsapp.net --log
|
|
238
|
+
|
|
239
|
+
# Installed as a dev dependency
|
|
240
|
+
npx fake-wa-server --port 5222 --peer 5511888@s.whatsapp.net --log
|
|
241
|
+
|
|
242
|
+
# First-time QR pairing: prompts on stdin for the QR payload the client displays
|
|
243
|
+
npx fake-wa-server --port 5222 --pair 5511999:1@s.whatsapp.net
|
|
244
|
+
|
|
245
|
+
# Print connection info (url + noise root CA hex) as JSON, for scripting
|
|
246
|
+
npx fake-wa-server --json
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
From inside this monorepo (runs the source, no build needed):
|
|
250
|
+
|
|
164
251
|
```bash
|
|
165
|
-
# Standalone server for manual testing
|
|
166
252
|
npm --workspace=@zapo-js/fake-server run cli -- --port 5222 --peer 5511888@s.whatsapp.net --log
|
|
167
253
|
```
|
|
168
254
|
|
|
255
|
+
Run with `--help` for the full flag list.
|
|
256
|
+
|
|
169
257
|
## Test suite
|
|
170
258
|
|
|
171
259
|
```bash
|
|
172
260
|
npm --workspace=@zapo-js/fake-server test
|
|
173
|
-
#
|
|
261
|
+
# 164 tests, --test-concurrency=1
|
|
174
262
|
```
|
|
175
263
|
|
|
176
264
|
Cross-check tests drive a real `WaClient` against the fake server and assert on both sides (lib emits correct events, peer decrypts correctly). Unit tests validate protocol builders/parsers in isolation.
|
package/dist/api/FakePeer.js
CHANGED
|
@@ -550,7 +550,13 @@ class FakePeer {
|
|
|
550
550
|
}
|
|
551
551
|
nextId() {
|
|
552
552
|
this.nextMessageCounter += 1;
|
|
553
|
-
|
|
553
|
+
// WA-style uppercase hex, no '@': strict binary decoders tokenize an
|
|
554
|
+
// id containing '@' as a JID and reject the stanza. The registration
|
|
555
|
+
// id keeps ids from colliding across peers created in the same ms.
|
|
556
|
+
const peerPart = this.keyBundle.registrationId.toString(16).padStart(4, '0');
|
|
557
|
+
const timePart = Date.now().toString(16);
|
|
558
|
+
const counterPart = this.nextMessageCounter.toString(16).padStart(4, '0');
|
|
559
|
+
return `3EB0${peerPart}${timePart}${counterPart}`.toUpperCase();
|
|
554
560
|
}
|
|
555
561
|
}
|
|
556
562
|
exports.FakePeer = FakePeer;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
|
|
2
|
+
import type { BuildAbPropsResultInput } from '../protocol/iq/abprops';
|
|
3
|
+
import { type WaFakeIqMatcher, type WaFakeIqResponder, WaFakeIqRouter } from '../protocol/iq/router';
|
|
4
|
+
import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
|
|
5
|
+
import { type BinaryNode } from '../transport/codec';
|
|
6
|
+
import { AppStateSyncManager } from './AppStateSyncManager';
|
|
7
|
+
import { PreKeyDispenser } from './PreKeyDispenser';
|
|
8
|
+
import { ServerRegistries } from './ServerRegistries';
|
|
9
|
+
export interface ExpectIqOptions {
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ExpectStanzaOptions {
|
|
13
|
+
readonly timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface StanzaMatcher {
|
|
16
|
+
readonly tag?: string;
|
|
17
|
+
readonly type?: string;
|
|
18
|
+
readonly xmlns?: string;
|
|
19
|
+
readonly from?: string;
|
|
20
|
+
readonly to?: string;
|
|
21
|
+
readonly childTag?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Shared server-side dependencies a session needs but does not own. The media
|
|
25
|
+
* HTTPS server is process-global (one listener), so its address is injected
|
|
26
|
+
* rather than duplicated per session.
|
|
27
|
+
*/
|
|
28
|
+
export interface FakeServerSessionHost {
|
|
29
|
+
requireMediaHttpsInfo(): {
|
|
30
|
+
readonly host: string;
|
|
31
|
+
readonly port: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One isolated logical device's worth of server state. Every `WaClient`
|
|
36
|
+
* connected to a `FakeWaServer` is bound to exactly one session; peers,
|
|
37
|
+
* groups, privacy, prekeys, app-state, and captured stanzas never leak across
|
|
38
|
+
* sessions. A single-client server uses one default session, which is why the
|
|
39
|
+
* legacy `FakeWaServer` surface delegates straight through to it.
|
|
40
|
+
*/
|
|
41
|
+
export declare class FakeServerSession {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly registries: ServerRegistries;
|
|
44
|
+
readonly preKeyDispenser: PreKeyDispenser;
|
|
45
|
+
readonly appStateSync: AppStateSyncManager;
|
|
46
|
+
readonly iqRouter: WaFakeIqRouter;
|
|
47
|
+
private readonly capturedStanzas;
|
|
48
|
+
private readonly pendingIqExpectations;
|
|
49
|
+
private readonly pendingStanzaExpectations;
|
|
50
|
+
private readonly capturedStanzaListeners;
|
|
51
|
+
private readonly inboundStanzaListeners;
|
|
52
|
+
constructor(id: string, host: FakeServerSessionHost, options: {
|
|
53
|
+
readonly defaultIqHandlers: boolean;
|
|
54
|
+
});
|
|
55
|
+
registerIqHandler(matcher: WaFakeIqMatcher, respond: WaFakeIqResponder, label?: string): () => void;
|
|
56
|
+
routeIq(iq: BinaryNode): Promise<BinaryNode | null>;
|
|
57
|
+
handleCapturedStanza(node: BinaryNode): void;
|
|
58
|
+
subscribeInboundMessages(listener: (node: BinaryNode) => void): () => void;
|
|
59
|
+
onCapturedStanza(listener: (node: BinaryNode) => void): () => void;
|
|
60
|
+
capturedStanzaSnapshot(): readonly BinaryNode[];
|
|
61
|
+
expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
|
|
62
|
+
expectStanza(matcher: StanzaMatcher, options?: ExpectStanzaOptions): Promise<BinaryNode>;
|
|
63
|
+
private buildIqHandlerDeps;
|
|
64
|
+
}
|
|
65
|
+
export declare function matchesIq(iq: BinaryNode, matcher: WaFakeIqMatcher): boolean;
|
|
66
|
+
export declare function matchesStanza(node: BinaryNode, matcher: StanzaMatcher): boolean;
|
|
67
|
+
export type { ClientPreKeyBundle, BuildAbPropsResultInput };
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.FakeServerSession = void 0;
|
|
5
|
+
exports.matchesIq = matchesIq;
|
|
6
|
+
exports.matchesStanza = matchesStanza;
|
|
7
|
+
const router_1 = require("../protocol/iq/router");
|
|
8
|
+
const AppStateSyncManager_1 = require("./AppStateSyncManager");
|
|
9
|
+
const iq_handlers_1 = require("./iq-handlers");
|
|
10
|
+
const PreKeyDispenser_1 = require("./PreKeyDispenser");
|
|
11
|
+
const ServerRegistries_1 = require("./ServerRegistries");
|
|
12
|
+
/**
|
|
13
|
+
* One isolated logical device's worth of server state. Every `WaClient`
|
|
14
|
+
* connected to a `FakeWaServer` is bound to exactly one session; peers,
|
|
15
|
+
* groups, privacy, prekeys, app-state, and captured stanzas never leak across
|
|
16
|
+
* sessions. A single-client server uses one default session, which is why the
|
|
17
|
+
* legacy `FakeWaServer` surface delegates straight through to it.
|
|
18
|
+
*/
|
|
19
|
+
class FakeServerSession {
|
|
20
|
+
constructor(id, host, options) {
|
|
21
|
+
this.registries = new ServerRegistries_1.ServerRegistries();
|
|
22
|
+
this.preKeyDispenser = new PreKeyDispenser_1.PreKeyDispenser();
|
|
23
|
+
this.appStateSync = new AppStateSyncManager_1.AppStateSyncManager();
|
|
24
|
+
this.iqRouter = new router_1.WaFakeIqRouter();
|
|
25
|
+
this.capturedStanzas = [];
|
|
26
|
+
this.pendingIqExpectations = new Set();
|
|
27
|
+
this.pendingStanzaExpectations = new Set();
|
|
28
|
+
this.capturedStanzaListeners = new Set();
|
|
29
|
+
this.inboundStanzaListeners = new Set();
|
|
30
|
+
this.id = id;
|
|
31
|
+
if (options.defaultIqHandlers) {
|
|
32
|
+
(0, iq_handlers_1.registerDefaultIqHandlers)(this.iqRouter, this.buildIqHandlerDeps(host));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
registerIqHandler(matcher, respond, label) {
|
|
36
|
+
const handler = { matcher, respond, label };
|
|
37
|
+
return this.iqRouter.register(handler, { priority: 'high' });
|
|
38
|
+
}
|
|
39
|
+
routeIq(iq) {
|
|
40
|
+
return this.iqRouter.route(iq);
|
|
41
|
+
}
|
|
42
|
+
handleCapturedStanza(node) {
|
|
43
|
+
this.capturedStanzas.push(node);
|
|
44
|
+
for (const listener of this.capturedStanzaListeners) {
|
|
45
|
+
try {
|
|
46
|
+
listener(node);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
void error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const listener of this.inboundStanzaListeners) {
|
|
53
|
+
try {
|
|
54
|
+
listener(node);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
void error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const expectation of this.pendingStanzaExpectations) {
|
|
61
|
+
if (matchesStanza(node, expectation.matcher)) {
|
|
62
|
+
expectation.resolve(node);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (node.tag !== 'iq') {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const expectation of this.pendingIqExpectations) {
|
|
70
|
+
if (matchesIq(node, expectation.matcher)) {
|
|
71
|
+
expectation.resolve(node);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
subscribeInboundMessages(listener) {
|
|
77
|
+
const wrapped = (node) => {
|
|
78
|
+
if (node.tag !== 'message')
|
|
79
|
+
return;
|
|
80
|
+
listener(node);
|
|
81
|
+
};
|
|
82
|
+
this.inboundStanzaListeners.add(wrapped);
|
|
83
|
+
return () => {
|
|
84
|
+
this.inboundStanzaListeners.delete(wrapped);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
onCapturedStanza(listener) {
|
|
88
|
+
this.capturedStanzaListeners.add(listener);
|
|
89
|
+
return () => {
|
|
90
|
+
this.capturedStanzaListeners.delete(listener);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
capturedStanzaSnapshot() {
|
|
94
|
+
return this.capturedStanzas.slice();
|
|
95
|
+
}
|
|
96
|
+
expectIq(matcher, options = {}) {
|
|
97
|
+
const timeoutMs = options.timeoutMs ?? 2000;
|
|
98
|
+
for (const captured of this.capturedStanzas) {
|
|
99
|
+
if (matchesIq(captured, matcher)) {
|
|
100
|
+
return Promise.resolve(captured);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const expectation = {
|
|
105
|
+
matcher,
|
|
106
|
+
resolve: (iq) => {
|
|
107
|
+
this.pendingIqExpectations.delete(expectation);
|
|
108
|
+
clearTimeout(expectation.timer);
|
|
109
|
+
resolve(iq);
|
|
110
|
+
},
|
|
111
|
+
reject: (error) => {
|
|
112
|
+
this.pendingIqExpectations.delete(expectation);
|
|
113
|
+
clearTimeout(expectation.timer);
|
|
114
|
+
reject(error);
|
|
115
|
+
},
|
|
116
|
+
timer: setTimeout(() => {
|
|
117
|
+
this.pendingIqExpectations.delete(expectation);
|
|
118
|
+
reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
|
|
119
|
+
}, timeoutMs)
|
|
120
|
+
};
|
|
121
|
+
this.pendingIqExpectations.add(expectation);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
expectStanza(matcher, options = {}) {
|
|
125
|
+
const timeoutMs = options.timeoutMs ?? 2000;
|
|
126
|
+
for (const captured of this.capturedStanzas) {
|
|
127
|
+
if (matchesStanza(captured, matcher)) {
|
|
128
|
+
return Promise.resolve(captured);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const expectation = {
|
|
133
|
+
matcher,
|
|
134
|
+
resolve: (node) => {
|
|
135
|
+
this.pendingStanzaExpectations.delete(expectation);
|
|
136
|
+
clearTimeout(expectation.timer);
|
|
137
|
+
resolve(node);
|
|
138
|
+
},
|
|
139
|
+
reject: (error) => {
|
|
140
|
+
this.pendingStanzaExpectations.delete(expectation);
|
|
141
|
+
clearTimeout(expectation.timer);
|
|
142
|
+
reject(error);
|
|
143
|
+
},
|
|
144
|
+
timer: setTimeout(() => {
|
|
145
|
+
this.pendingStanzaExpectations.delete(expectation);
|
|
146
|
+
reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
|
|
147
|
+
}, timeoutMs)
|
|
148
|
+
};
|
|
149
|
+
this.pendingStanzaExpectations.add(expectation);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
buildIqHandlerDeps(host) {
|
|
153
|
+
const reg = this.registries;
|
|
154
|
+
const preKey = this.preKeyDispenser;
|
|
155
|
+
const appState = this.appStateSync;
|
|
156
|
+
return {
|
|
157
|
+
get peerRegistry() {
|
|
158
|
+
return reg.peerRegistry;
|
|
159
|
+
},
|
|
160
|
+
get groupRegistry() {
|
|
161
|
+
return reg.groupRegistry;
|
|
162
|
+
},
|
|
163
|
+
get privacySettings() {
|
|
164
|
+
return reg.privacySettings;
|
|
165
|
+
},
|
|
166
|
+
get blocklistJids() {
|
|
167
|
+
return reg.blocklistJids;
|
|
168
|
+
},
|
|
169
|
+
get profilePicturesByJid() {
|
|
170
|
+
return reg.profilePicturesByJid;
|
|
171
|
+
},
|
|
172
|
+
get businessProfilesByJid() {
|
|
173
|
+
return reg.businessProfilesByJid;
|
|
174
|
+
},
|
|
175
|
+
get abPropsInput() {
|
|
176
|
+
return reg.abPropsInput;
|
|
177
|
+
},
|
|
178
|
+
get issuedPrivacyTokens() {
|
|
179
|
+
return reg.issuedPrivacyTokens;
|
|
180
|
+
},
|
|
181
|
+
get latestStatusText() {
|
|
182
|
+
return reg.latestStatusText;
|
|
183
|
+
},
|
|
184
|
+
setLatestStatusText: (text) => {
|
|
185
|
+
reg.latestStatusText = text;
|
|
186
|
+
},
|
|
187
|
+
lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
|
|
188
|
+
notifyGroupOp: (op) => reg.notifyGroupOp(op),
|
|
189
|
+
mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
|
|
190
|
+
mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
|
|
191
|
+
notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
|
|
192
|
+
handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
|
|
193
|
+
notifyStatusSet: (text) => reg.notifyStatusSet(text),
|
|
194
|
+
notifyLogout: () => reg.notifyLogout(),
|
|
195
|
+
notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
|
|
196
|
+
notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
|
|
197
|
+
notifyPrivacySet: (change) => {
|
|
198
|
+
for (const listener of reg.privacySetListeners) {
|
|
199
|
+
try {
|
|
200
|
+
listener(change);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
void error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
notifyBlocklistChange: (change) => {
|
|
208
|
+
for (const listener of reg.blocklistChangeListeners) {
|
|
209
|
+
try {
|
|
210
|
+
listener(change);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
void error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
|
|
218
|
+
countServerPreKeys: () => preKey.preKeysAvailable(),
|
|
219
|
+
consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
|
|
220
|
+
get appStateCollectionProviders() {
|
|
221
|
+
return appState.appStateCollectionProviders;
|
|
222
|
+
},
|
|
223
|
+
requireMediaHttpsInfo: () => host.requireMediaHttpsInfo()
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
exports.FakeServerSession = FakeServerSession;
|
|
228
|
+
function matchesIq(iq, matcher) {
|
|
229
|
+
if (iq.tag !== 'iq')
|
|
230
|
+
return false;
|
|
231
|
+
if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
|
|
232
|
+
return false;
|
|
233
|
+
if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
|
|
234
|
+
return false;
|
|
235
|
+
if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
|
|
236
|
+
return false;
|
|
237
|
+
if (matcher.childTag !== undefined) {
|
|
238
|
+
const children = Array.isArray(iq.content) ? iq.content : null;
|
|
239
|
+
if (!children || children.length === 0)
|
|
240
|
+
return false;
|
|
241
|
+
if (children[0].tag !== matcher.childTag)
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
function describeMatcher(matcher) {
|
|
247
|
+
const parts = [];
|
|
248
|
+
if (matcher.id !== undefined)
|
|
249
|
+
parts.push(`id=${matcher.id}`);
|
|
250
|
+
if (matcher.type !== undefined)
|
|
251
|
+
parts.push(`type=${matcher.type}`);
|
|
252
|
+
if (matcher.xmlns !== undefined)
|
|
253
|
+
parts.push(`xmlns=${matcher.xmlns}`);
|
|
254
|
+
if (matcher.childTag !== undefined)
|
|
255
|
+
parts.push(`childTag=${matcher.childTag}`);
|
|
256
|
+
return parts.length > 0 ? parts.join(', ') : 'any iq';
|
|
257
|
+
}
|
|
258
|
+
function matchesStanza(node, matcher) {
|
|
259
|
+
if (matcher.tag !== undefined && node.tag !== matcher.tag)
|
|
260
|
+
return false;
|
|
261
|
+
if (matcher.type !== undefined && node.attrs.type !== matcher.type)
|
|
262
|
+
return false;
|
|
263
|
+
if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
|
|
264
|
+
return false;
|
|
265
|
+
if (matcher.from !== undefined && node.attrs.from !== matcher.from)
|
|
266
|
+
return false;
|
|
267
|
+
if (matcher.to !== undefined && node.attrs.to !== matcher.to)
|
|
268
|
+
return false;
|
|
269
|
+
if (matcher.childTag !== undefined) {
|
|
270
|
+
const children = Array.isArray(node.content) ? node.content : null;
|
|
271
|
+
if (!children || children.length === 0)
|
|
272
|
+
return false;
|
|
273
|
+
if (children[0].tag !== matcher.childTag)
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
function describeStanzaMatcher(matcher) {
|
|
279
|
+
const parts = [];
|
|
280
|
+
if (matcher.tag !== undefined)
|
|
281
|
+
parts.push(`tag=${matcher.tag}`);
|
|
282
|
+
if (matcher.type !== undefined)
|
|
283
|
+
parts.push(`type=${matcher.type}`);
|
|
284
|
+
if (matcher.xmlns !== undefined)
|
|
285
|
+
parts.push(`xmlns=${matcher.xmlns}`);
|
|
286
|
+
if (matcher.from !== undefined)
|
|
287
|
+
parts.push(`from=${matcher.from}`);
|
|
288
|
+
if (matcher.to !== undefined)
|
|
289
|
+
parts.push(`to=${matcher.to}`);
|
|
290
|
+
if (matcher.childTag !== undefined)
|
|
291
|
+
parts.push(`childTag=${matcher.childTag}`);
|
|
292
|
+
return parts.length > 0 ? parts.join(', ') : 'any stanza';
|
|
293
|
+
}
|