@zapo-js/fake-server 1.0.0 → 1.2.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.
Files changed (73) hide show
  1. package/README.md +180 -33
  2. package/dist/api/FakeMobilePrimary.d.ts +55 -0
  3. package/dist/api/FakeMobilePrimary.js +106 -0
  4. package/dist/api/FakePairingDriver.js +1 -3
  5. package/dist/api/FakePeer.js +7 -1
  6. package/dist/api/FakeServerSession.d.ts +100 -0
  7. package/dist/api/FakeServerSession.js +355 -0
  8. package/dist/api/FakeWaServer.d.ts +145 -37
  9. package/dist/api/FakeWaServer.js +345 -270
  10. package/dist/api/ServerRegistries.d.ts +7 -0
  11. package/dist/api/ServerRegistries.js +20 -5
  12. package/dist/api/iq-handlers.d.ts +20 -1
  13. package/dist/api/iq-handlers.js +119 -1
  14. package/dist/bin.js +69 -21
  15. package/dist/esm/api/FakeMobilePrimary.js +102 -0
  16. package/dist/esm/api/FakePairingDriver.js +2 -4
  17. package/dist/esm/api/FakePeer.js +7 -1
  18. package/dist/esm/api/FakeServerSession.js +349 -0
  19. package/dist/esm/api/FakeWaServer.js +344 -270
  20. package/dist/esm/api/ServerRegistries.js +20 -5
  21. package/dist/esm/api/iq-handlers.js +119 -1
  22. package/dist/esm/bin.js +67 -19
  23. package/dist/esm/index.js +9 -1
  24. package/dist/esm/infra/WaFakeConnection.js +8 -22
  25. package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
  26. package/dist/esm/infra/WaFakeTcpServer.js +69 -0
  27. package/dist/esm/infra/WaFakeWsServer.js +2 -1
  28. package/dist/esm/infra/socket-adapters.js +79 -0
  29. package/dist/esm/protocol/auth/cert-chain.js +13 -2
  30. package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
  31. package/dist/esm/protocol/auth/pair-device.js +16 -1
  32. package/dist/esm/protocol/iq/companion-host.js +104 -0
  33. package/dist/esm/protocol/iq/link-code.js +90 -0
  34. package/dist/esm/protocol/iq/router.js +9 -3
  35. package/dist/esm/protocol/push/mobile-notification.js +97 -0
  36. package/dist/esm/state/fake-companion-host.js +158 -0
  37. package/dist/esm/transport/auth.js +7 -0
  38. package/dist/esm/transport/crypto.js +1 -1
  39. package/dist/esm/transport/util.js +2 -1
  40. package/dist/index.d.ts +19 -4
  41. package/dist/index.js +29 -1
  42. package/dist/infra/WaFakeConnection.d.ts +21 -3
  43. package/dist/infra/WaFakeConnection.js +8 -22
  44. package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
  45. package/dist/infra/WaFakeConnectionPipeline.js +38 -14
  46. package/dist/infra/WaFakeTcpServer.d.ts +29 -0
  47. package/dist/infra/WaFakeTcpServer.js +73 -0
  48. package/dist/infra/WaFakeWsServer.js +2 -1
  49. package/dist/infra/socket-adapters.d.ts +18 -0
  50. package/dist/infra/socket-adapters.js +83 -0
  51. package/dist/protocol/auth/cert-chain.d.ts +4 -0
  52. package/dist/protocol/auth/cert-chain.js +13 -2
  53. package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
  54. package/dist/protocol/auth/client-payload-validate.js +77 -1
  55. package/dist/protocol/auth/pair-device.d.ts +15 -1
  56. package/dist/protocol/auth/pair-device.js +16 -0
  57. package/dist/protocol/iq/companion-host.d.ts +52 -0
  58. package/dist/protocol/iq/companion-host.js +109 -0
  59. package/dist/protocol/iq/link-code.d.ts +46 -0
  60. package/dist/protocol/iq/link-code.js +95 -0
  61. package/dist/protocol/iq/router.d.ts +21 -2
  62. package/dist/protocol/iq/router.js +9 -3
  63. package/dist/protocol/push/mobile-notification.d.ts +58 -0
  64. package/dist/protocol/push/mobile-notification.js +102 -0
  65. package/dist/state/fake-companion-host.d.ts +73 -0
  66. package/dist/state/fake-companion-host.js +163 -0
  67. package/dist/transport/auth.d.ts +8 -0
  68. package/dist/transport/auth.js +8 -0
  69. package/dist/transport/crypto.d.ts +1 -1
  70. package/dist/transport/crypto.js +2 -1
  71. package/dist/transport/util.d.ts +2 -1
  72. package/dist/transport/util.js +3 -1
  73. package/package.json +6 -1
@@ -0,0 +1,79 @@
1
+ /** Carrier adapters: WebSocket (companion transport) and raw TCP (mobile). */
2
+ import { TEXT_DECODER } from '../transport/util.js';
3
+ /**
4
+ * Adapts a `ws` socket to {@link WaFakeSocketLike}. Owns the two WebSocket
5
+ * quirks the rest of the server should not know about: a message can arrive as
6
+ * a fragment array, and a text frame is a protocol violation here because every
7
+ * WhatsApp frame is binary.
8
+ */
9
+ export function createWebSocketAdapter(socket) {
10
+ return {
11
+ send: (frame) => socket.send(frame),
12
+ close: (code, reason) => socket.close(code, reason),
13
+ listen: (events) => {
14
+ socket.on('message', (data, isBinary) => {
15
+ if (!isBinary) {
16
+ events.onError(new Error('received unexpected text frame'));
17
+ return;
18
+ }
19
+ events.onFrame(toFrameBytes(data));
20
+ });
21
+ socket.on('close', (code, reason) => {
22
+ events.onClose({ code, reason: TEXT_DECODER.decode(reason) });
23
+ });
24
+ socket.on('error', (error) => {
25
+ events.onError(error);
26
+ });
27
+ }
28
+ };
29
+ }
30
+ /**
31
+ * Adapts a Node TCP socket to {@link WaFakeSocketLike} for the mobile
32
+ * transport. A TCP stream has no message boundaries and no close codes: every
33
+ * chunk is forwarded as-is (the frame codec above reassembles them) and the
34
+ * code/reason the server asked for is replayed when the socket finally closes.
35
+ */
36
+ export function createTcpSocketAdapter(socket) {
37
+ let closeCode = 1000;
38
+ let closeReason = '';
39
+ return {
40
+ send: (frame) => {
41
+ socket.write(frame);
42
+ },
43
+ close: (code, reason) => {
44
+ closeCode = code;
45
+ closeReason = reason;
46
+ socket.end();
47
+ },
48
+ listen: (events) => {
49
+ socket.on('data', (chunk) => {
50
+ events.onFrame(chunk);
51
+ });
52
+ socket.on('close', () => {
53
+ events.onClose({ code: closeCode, reason: closeReason });
54
+ });
55
+ socket.on('error', (error) => {
56
+ events.onError(error);
57
+ });
58
+ }
59
+ };
60
+ }
61
+ function toFrameBytes(data) {
62
+ if (data instanceof Uint8Array) {
63
+ return data;
64
+ }
65
+ if (data instanceof ArrayBuffer) {
66
+ return new Uint8Array(data);
67
+ }
68
+ let total = 0;
69
+ for (const part of data) {
70
+ total += part.byteLength;
71
+ }
72
+ const out = new Uint8Array(total);
73
+ let offset = 0;
74
+ for (const part of data) {
75
+ out.set(part, offset);
76
+ offset += part.byteLength;
77
+ }
78
+ return out;
79
+ }
@@ -1,6 +1,8 @@
1
1
  /** Fake noise certificate chain generator for handshake tests. */
2
2
  import { X25519, xeddsaSign } from '../../transport/crypto.js';
3
3
  import { proto } from '../../transport/protos.js';
4
+ const WA_CERT_NOT_BEFORE_BACKDATE_SECONDS = 86400;
5
+ const WA_CERT_NOT_AFTER_LIFETIME_SECONDS = 315360000;
4
6
  export async function generateFakeNoiseRootCa() {
5
7
  const keyPair = await X25519.generateKeyPair();
6
8
  return {
@@ -12,17 +14,26 @@ export async function generateFakeNoiseRootCa() {
12
14
  export async function buildFakeCertChain(input) {
13
15
  const intermediateSerial = input.intermediateSerial ?? 1;
14
16
  const leafSerial = input.leafSerial ?? 2;
17
+ // Strict clients (e.g. whatsmeow) enforce the validity window; leaving
18
+ // notBefore/notAfter unset decodes as 0 (1970) and reads as expired.
19
+ const nowSeconds = Math.floor(Date.now() / 1000);
20
+ const notBefore = input.notBefore ?? nowSeconds - WA_CERT_NOT_BEFORE_BACKDATE_SECONDS;
21
+ const notAfter = input.notAfter ?? nowSeconds + WA_CERT_NOT_AFTER_LIFETIME_SECONDS;
15
22
  const intermediateKeyPair = await X25519.generateKeyPair();
16
23
  const intermediateDetails = proto.CertChain.NoiseCertificate.Details.encode({
17
24
  serial: intermediateSerial,
18
25
  issuerSerial: input.root.serial,
19
- key: intermediateKeyPair.pubKey
26
+ key: intermediateKeyPair.pubKey,
27
+ notBefore,
28
+ notAfter
20
29
  }).finish();
21
30
  const intermediateSignature = await xeddsaSign(input.root.privateKey, intermediateDetails);
22
31
  const leafDetails = proto.CertChain.NoiseCertificate.Details.encode({
23
32
  serial: leafSerial,
24
33
  issuerSerial: intermediateSerial,
25
- key: input.leafKey
34
+ key: input.leafKey,
35
+ notBefore,
36
+ notAfter
26
37
  }).finish();
27
38
  const leafSignature = await xeddsaSign(intermediateKeyPair.privKey, leafDetails);
28
39
  const encoded = proto.CertChain.encode({
@@ -27,14 +27,90 @@ export function parseClientPayload(bytes) {
27
27
  if (raw.username === undefined || raw.username === null) {
28
28
  throw new ClientPayloadValidationError('missing_username', 'login ClientPayload is missing the username field');
29
29
  }
30
+ const flavor = resolveLoginFlavor(raw);
30
31
  return {
31
32
  kind: 'login',
32
33
  raw,
33
34
  username: String(raw.username),
34
35
  device: typeof raw.device === 'number' ? raw.device : 0,
35
- loginCounter: typeof raw.lc === 'number' ? raw.lc : 0
36
+ loginCounter: typeof raw.lc === 'number' ? raw.lc : 0,
37
+ flavor,
38
+ mobile: flavor === 'mobile' ? parseMobileDetails(raw) : null
36
39
  };
37
40
  }
41
+ /**
42
+ * Resolves the transport flavor of a login payload. `webInfo` and the `WEB`
43
+ * platform mark a companion; a phone is identified by the device fields only
44
+ * the mobile payload carries. Sniffing those fields (rather than the platform
45
+ * enum alone) is deliberate: `ANDROID` is the zero value of the enum, so a
46
+ * payload that omits `platform` entirely would otherwise read as mobile.
47
+ */
48
+ function resolveLoginFlavor(raw) {
49
+ if (raw.webInfo) {
50
+ return 'web';
51
+ }
52
+ const userAgent = raw.userAgent;
53
+ if (!userAgent || userAgent.platform === proto.ClientPayload.UserAgent.Platform.WEB) {
54
+ return 'web';
55
+ }
56
+ return userAgent.manufacturer && userAgent.device ? 'mobile' : 'web';
57
+ }
58
+ function parseMobileDetails(raw) {
59
+ const userAgent = raw.userAgent;
60
+ if (!userAgent) {
61
+ throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload is missing the userAgent');
62
+ }
63
+ const required = [
64
+ ['manufacturer', 'device manufacturer'],
65
+ ['device', 'device model'],
66
+ ['osVersion', 'os version'],
67
+ ['osBuildNumber', 'os build number'],
68
+ ['phoneId', 'phone id']
69
+ ];
70
+ for (const [field, label] of required) {
71
+ const value = userAgent[field];
72
+ if (value === undefined || value === null || value === '') {
73
+ throw new ClientPayloadValidationError('missing_field', `mobile login ClientPayload missing ${label} (userAgent.${String(field)})`);
74
+ }
75
+ }
76
+ const appVersion = userAgent.appVersion;
77
+ if (!appVersion || appVersion.primary === undefined || appVersion.primary === null) {
78
+ throw new ClientPayloadValidationError('missing_field', 'mobile login ClientPayload missing the app version (userAgent.appVersion)');
79
+ }
80
+ return {
81
+ manufacturer: String(userAgent.manufacturer),
82
+ device: String(userAgent.device),
83
+ osVersion: String(userAgent.osVersion),
84
+ osBuildNumber: String(userAgent.osBuildNumber),
85
+ appVersion: formatAppVersion(appVersion),
86
+ phoneId: String(userAgent.phoneId),
87
+ mcc: userAgent.mcc ? String(userAgent.mcc) : '000',
88
+ mnc: userAgent.mnc ? String(userAgent.mnc) : '000',
89
+ pushName: typeof raw.pushName === 'string' ? raw.pushName : null,
90
+ yearClass: toOptionalNumber(raw.yearClass),
91
+ memClass: toOptionalNumber(raw.memClass)
92
+ };
93
+ }
94
+ function formatAppVersion(version) {
95
+ const parts = [
96
+ version.primary,
97
+ version.secondary,
98
+ version.tertiary,
99
+ version.quaternary,
100
+ version.quinary
101
+ ];
102
+ const present = [];
103
+ for (const part of parts) {
104
+ if (part === undefined || part === null) {
105
+ break;
106
+ }
107
+ present.push(Number(part));
108
+ }
109
+ return present.join('.');
110
+ }
111
+ function toOptionalNumber(value) {
112
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
113
+ }
38
114
  function validateRegistrationFields(devicePairingData) {
39
115
  const required = [
40
116
  ['eIdent', 'identity public key'],
@@ -1,5 +1,20 @@
1
1
  /** Pairing IQ builders/parsers (source: `WAWebHandlePairDevice*`, `/wa-web`). */
2
- import { decodeBase64Url } from '../../transport/util.js';
2
+ import { randomBytesAsync } from '../../transport/crypto.js';
3
+ import { bytesToBase64UrlSafe, decodeBase64Url } from '../../transport/util.js';
4
+ const PAIR_DEVICE_REF_COUNT = 6;
5
+ const PAIR_DEVICE_REF_BYTES = 16;
6
+ /**
7
+ * Mints the rotating refs a `pair-device` push carries.
8
+ *
9
+ * They are printable on purpose. A client reads `<ref>` as text and pastes it
10
+ * into a comma-separated QR payload, so raw random bytes can decode into a
11
+ * comma or a newline and quietly corrupt whatever reads that payload back -
12
+ * including the primary that has to echo the ref in its upload.
13
+ */
14
+ export async function mintPairingRefs(count = PAIR_DEVICE_REF_COUNT) {
15
+ const raw = await Promise.all(Array.from({ length: count }, () => randomBytesAsync(PAIR_DEVICE_REF_BYTES)));
16
+ return raw.map((bytes) => bytesToBase64UrlSafe(bytes));
17
+ }
3
18
  export function buildPairDeviceIq(input) {
4
19
  if (input.refs.length !== 6) {
5
20
  throw new Error(`pair-device requires exactly 6 refs, got ${input.refs.length}`);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Primary-side `md` IQ parsers: a phone hosting companion devices.
3
+ *
4
+ * These are the inverse of `protocol/auth/pair-device.ts`, which drives the
5
+ * companion side. Here the client is the primary: it signs a companion's device
6
+ * identity and uploads it, republishes its key-index list, and revokes devices.
7
+ */
8
+ import { TEXT_DECODER } from '../../transport/util.js';
9
+ /**
10
+ * Parses the primary's `<iq type="set" xmlns="md"><pair-device>` upload.
11
+ * Returns `null` when the stanza is not a well-formed pair-device upload, which
12
+ * lets the router fall through to another handler.
13
+ */
14
+ export function parsePairDeviceUpload(iq) {
15
+ const pairDevice = findChild(iq, 'pair-device');
16
+ if (!pairDevice) {
17
+ return null;
18
+ }
19
+ const ref = readText(findChild(pairDevice, 'ref'));
20
+ const companionNoisePublicKey = readBytes(findChild(pairDevice, 'pub-key'));
21
+ const deviceIdentityBytes = readBytes(findChild(pairDevice, 'device-identity'));
22
+ const keyIndexList = findChild(pairDevice, 'key-index-list');
23
+ const keyIndexListBytes = readBytes(keyIndexList);
24
+ if (!ref || !companionNoisePublicKey || !deviceIdentityBytes || !keyIndexListBytes) {
25
+ return null;
26
+ }
27
+ return {
28
+ ref,
29
+ companionNoisePublicKey,
30
+ deviceIdentityBytes,
31
+ keyIndexListBytes,
32
+ keyIndexListTimestampSeconds: readTimestampSeconds(keyIndexList),
33
+ clientPropsBytes: readBytes(findChild(pairDevice, 'client-props')),
34
+ pemKeyBytes: readPemKey(pairDevice)
35
+ };
36
+ }
37
+ /**
38
+ * Parses the standalone key-index-list republish the primary sends whenever its
39
+ * companion set changes. Returns `null` when the stanza carries no list.
40
+ */
41
+ export function parseKeyIndexListPublish(iq) {
42
+ const keyIndexList = findChild(iq, 'key-index-list');
43
+ const keyIndexListBytes = readBytes(keyIndexList);
44
+ if (!keyIndexListBytes) {
45
+ return null;
46
+ }
47
+ return {
48
+ keyIndexListBytes,
49
+ timestampSeconds: readTimestampSeconds(keyIndexList)
50
+ };
51
+ }
52
+ /**
53
+ * Parses `<remove-companion-device>`. The same stanza serves a companion
54
+ * logging itself out (`jid` = its own device) and a primary unlinking one of
55
+ * its companions, so the caller decides by comparing against the connection's
56
+ * own jid.
57
+ */
58
+ export function parseRemoveCompanionDevice(iq) {
59
+ const remove = findChild(iq, 'remove-companion-device');
60
+ if (!remove) {
61
+ return null;
62
+ }
63
+ const jid = remove.attrs.jid;
64
+ return {
65
+ deviceJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
66
+ all: remove.attrs.all === 'true',
67
+ reason: typeof remove.attrs.reason === 'string' ? remove.attrs.reason : '',
68
+ excludeHostedCompanion: remove.attrs.exclude_hosted_companion === 'true'
69
+ };
70
+ }
71
+ function findChild(parent, tag) {
72
+ if (!Array.isArray(parent.content)) {
73
+ return null;
74
+ }
75
+ return parent.content.find((child) => child.tag === tag) ?? null;
76
+ }
77
+ function readBytes(node) {
78
+ if (!node) {
79
+ return null;
80
+ }
81
+ return node.content instanceof Uint8Array ? node.content : null;
82
+ }
83
+ function readText(node) {
84
+ if (!node) {
85
+ return null;
86
+ }
87
+ if (typeof node.content === 'string') {
88
+ return node.content;
89
+ }
90
+ return node.content instanceof Uint8Array ? TEXT_DECODER.decode(node.content) : null;
91
+ }
92
+ function readTimestampSeconds(node) {
93
+ const raw = node?.attrs.ts;
94
+ const parsed = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN;
95
+ return Number.isFinite(parsed) ? parsed : 0;
96
+ }
97
+ /** The `<pem>` wrapper nests the key bytes in a second `<pem>` element. */
98
+ function readPemKey(pairDevice) {
99
+ const pem = findChild(pairDevice, 'pem');
100
+ if (!pem) {
101
+ return null;
102
+ }
103
+ return readBytes(findChild(pem, 'pem'));
104
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Link-code (pairing-code) relay stanzas.
3
+ *
4
+ * The handshake runs between two clients with the server in the middle: the
5
+ * companion asks for a code (`companion_hello`), the primary answers with its
6
+ * wrapped ephemeral (`primary_hello`), and the companion closes with the key
7
+ * bundle (`companion_finish`). Each stage arrives as an IQ from one client and
8
+ * leaves as a notification to the other; the server only routes and mints the
9
+ * ref that ties the three together.
10
+ */
11
+ import { TEXT_DECODER } from '../../transport/util.js';
12
+ const RELAYED_STAGES = new Set([
13
+ 'companion_hello',
14
+ 'primary_hello',
15
+ 'companion_finish'
16
+ ]);
17
+ /**
18
+ * Parses a `<link_code_companion_reg>` IQ. Returns `null` for a stage this
19
+ * server does not relay (for example `get_country_code`), letting the router
20
+ * fall through to another handler.
21
+ */
22
+ export function parseLinkCodeStanza(iq) {
23
+ const node = findChild(iq, 'link_code_companion_reg');
24
+ const stage = node?.attrs.stage;
25
+ if (!node || typeof stage !== 'string' || !RELAYED_STAGES.has(stage)) {
26
+ return null;
27
+ }
28
+ const jid = node.attrs.jid;
29
+ return {
30
+ stage: stage,
31
+ phoneJid: typeof jid === 'string' && jid.length > 0 ? jid : null,
32
+ ref: readText(findChild(node, 'link_code_pairing_ref')),
33
+ children: Array.isArray(node.content) ? node.content : []
34
+ };
35
+ }
36
+ /**
37
+ * Builds the `companion_hello` IQ result carrying the ref the server minted.
38
+ * The companion keys its whole pairing session off it.
39
+ */
40
+ export function buildCompanionHelloResultContent(ref) {
41
+ return [
42
+ {
43
+ tag: 'link_code_companion_reg',
44
+ attrs: { stage: 'companion_hello' },
45
+ content: [{ tag: 'link_code_pairing_ref', attrs: {}, content: ref }]
46
+ }
47
+ ];
48
+ }
49
+ /**
50
+ * Wraps a relayed stage as the notification the receiving client handles. The
51
+ * ref is (re)stamped so the receiver can match it against its pairing session,
52
+ * replacing any copy carried by the source stanza.
53
+ */
54
+ export function buildLinkCodeNotification(input) {
55
+ const children = input.children.filter((child) => child.tag !== 'link_code_pairing_ref');
56
+ return {
57
+ tag: 'notification',
58
+ attrs: {
59
+ id: input.id ?? `link-code-${Math.random().toString(36).slice(2, 10)}`,
60
+ from: 's.whatsapp.net',
61
+ type: 'link_code_companion_reg',
62
+ ...(input.to !== undefined ? { to: input.to } : {})
63
+ },
64
+ content: [
65
+ {
66
+ tag: 'link_code_companion_reg',
67
+ attrs: { stage: input.stage },
68
+ content: [
69
+ { tag: 'link_code_pairing_ref', attrs: {}, content: input.ref },
70
+ ...children
71
+ ]
72
+ }
73
+ ]
74
+ };
75
+ }
76
+ function findChild(parent, tag) {
77
+ if (!Array.isArray(parent.content)) {
78
+ return null;
79
+ }
80
+ return parent.content.find((child) => child.tag === tag) ?? null;
81
+ }
82
+ function readText(node) {
83
+ if (!node) {
84
+ return null;
85
+ }
86
+ if (typeof node.content === 'string') {
87
+ return node.content;
88
+ }
89
+ return node.content instanceof Uint8Array ? TEXT_DECODER.decode(node.content) : null;
90
+ }
@@ -19,18 +19,24 @@ export class WaFakeIqRouter {
19
19
  setEvents(events) {
20
20
  this.events = events;
21
21
  }
22
- async route(iq) {
22
+ async route(iq, context) {
23
23
  if (iq.tag !== 'iq') {
24
24
  return null;
25
25
  }
26
26
  for (const handler of this.highPriorityHandlers) {
27
27
  if (matches(iq, handler.matcher)) {
28
- return handler.respond(iq);
28
+ const response = await handler.respond(iq, context);
29
+ if (response !== null) {
30
+ return response;
31
+ }
29
32
  }
30
33
  }
31
34
  for (const handler of this.handlers) {
32
35
  if (matches(iq, handler.matcher)) {
33
- return handler.respond(iq);
36
+ const response = await handler.respond(iq, context);
37
+ if (response !== null) {
38
+ return response;
39
+ }
34
40
  }
35
41
  }
36
42
  this.events.onUnhandled?.(iq);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Push notifications only a phone receives.
3
+ *
4
+ * `account_sync` carries the account's live device set: the primary keys its
5
+ * companion bookkeeping off it, allocating a fresh key index for the next link
6
+ * and dropping companions the server no longer lists. The registration
7
+ * notifications carry the login code and the takeover warning that fire while
8
+ * a number is being moved to another phone.
9
+ */
10
+ /**
11
+ * Builds `<notification type="account_sync">` with the account's `<devices>`
12
+ * list. Every device the account still owns must be present: the primary reads
13
+ * this as authoritative and prunes anything missing from it.
14
+ */
15
+ export function buildAccountSyncDevicesNotification(input) {
16
+ return {
17
+ tag: 'notification',
18
+ attrs: {
19
+ id: input.id ?? `account-sync-${Math.random().toString(36).slice(2, 10)}`,
20
+ from: input.from ?? 's.whatsapp.net',
21
+ type: 'account_sync',
22
+ t: String(input.timestampSeconds ?? Math.floor(Date.now() / 1000))
23
+ },
24
+ content: [
25
+ {
26
+ tag: 'devices',
27
+ attrs: {},
28
+ content: input.devices.map((device) => ({
29
+ tag: 'device',
30
+ attrs: {
31
+ jid: device.deviceJid,
32
+ ...(device.keyIndex !== undefined
33
+ ? { 'key-index': String(device.keyIndex) }
34
+ : {}),
35
+ ...(device.deviceLid !== undefined ? { lid: device.deviceLid } : {})
36
+ }
37
+ }))
38
+ }
39
+ ]
40
+ };
41
+ }
42
+ /**
43
+ * Builds the `<notification type="registration">` carrying a login code, which
44
+ * the client surfaces as `mobile_registration_code`.
45
+ */
46
+ export function buildRegistrationCodeNotification(input) {
47
+ const nowSeconds = Math.floor(Date.now() / 1000);
48
+ return {
49
+ tag: 'notification',
50
+ attrs: {
51
+ id: input.id ?? `registration-${Math.random().toString(36).slice(2, 10)}`,
52
+ from: input.from ?? 's.whatsapp.net',
53
+ type: 'registration'
54
+ },
55
+ content: [
56
+ {
57
+ tag: 'wa_old_registration',
58
+ attrs: {
59
+ code: input.code,
60
+ expiry_t: String(input.expirySeconds ?? nowSeconds + 300),
61
+ device_id: input.fromDeviceId
62
+ }
63
+ }
64
+ ]
65
+ };
66
+ }
67
+ /**
68
+ * Builds the `<notification type="registration">` warning that the number is
69
+ * being registered on another phone, surfaced as
70
+ * `mobile_account_takeover_notice`.
71
+ */
72
+ export function buildAccountTakeoverNotice(input) {
73
+ return {
74
+ tag: 'notification',
75
+ attrs: {
76
+ id: input.id ?? `takeover-${Math.random().toString(36).slice(2, 10)}`,
77
+ from: input.from ?? 's.whatsapp.net',
78
+ type: 'registration'
79
+ },
80
+ content: [
81
+ {
82
+ tag: 'device_logout',
83
+ attrs: {
84
+ id: input.serverToken,
85
+ t: String(input.attemptTimestampSeconds ?? Math.floor(Date.now() / 1000)),
86
+ ...(input.newDeviceName !== undefined ? { device: input.newDeviceName } : {}),
87
+ ...(input.newDevicePlatform !== undefined
88
+ ? { new_device_platform: input.newDevicePlatform }
89
+ : {}),
90
+ ...(input.newDeviceAppVersion !== undefined
91
+ ? { new_device_app_version: input.newDeviceAppVersion }
92
+ : {})
93
+ }
94
+ }
95
+ ]
96
+ };
97
+ }