@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 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 (147 tests)
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. ~20 global handlers registered in the constructor cover every IQ the lib emits during normal operation.
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
- # 147 tests, --test-concurrency=1
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.
@@ -550,7 +550,13 @@ class FakePeer {
550
550
  }
551
551
  nextId() {
552
552
  this.nextMessageCounter += 1;
553
- return `${this.jid}-${Date.now()}-${this.nextMessageCounter}`;
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
+ }