zapo-js 1.8.2 → 1.9.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.
@@ -113,9 +113,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
113
113
  assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
114
114
  }
115
115
  if (effectiveMobileTransport) {
116
- if (wsProxy) {
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,
@@ -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
- if (wsProxy) {
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,
@@ -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,13 +1,14 @@
1
1
  import { connect as netConnect } from 'node:net';
2
2
  import { WA_READY_STATES } from '../../protocol/constants.js';
3
- import { TEXT_ENCODER } from '../../util/bytes.js';
3
+ import { toTcpProxyEndpoint } from '../proxy.js';
4
+ import { concatBytes, TEXT_DECODER, TEXT_ENCODER } from '../../util/bytes.js';
4
5
  /**
5
6
  * `RawWebSocket`-shaped adapter over a raw Node TCP socket. Used by the
6
7
  * mobile transport to speak the WhatsApp Mobile binary protocol over
7
8
  * `tcp://host:port` URLs.
8
9
  */
9
10
  export class WaMobileTcpSocket {
10
- constructor(url, _protocols, _options) {
11
+ constructor(url, _protocols, options) {
11
12
  this.binaryType = 'arraybuffer';
12
13
  this.readyState = WA_READY_STATES.CONNECTING;
13
14
  this.onopen = null;
@@ -19,19 +20,55 @@ export class WaMobileTcpSocket {
19
20
  this.closedClean = true;
20
21
  this.forceCloseTimer = null;
21
22
  const { host, port } = parseTcpUrl(url);
22
- this.socket = netConnect({ host, port });
23
+ const proxy = toTcpProxyEndpoint(options?.agent);
24
+ this.socket = proxy
25
+ ? netConnect({ host: proxy.hostname, port: proxy.port })
26
+ : netConnect({ host, port });
27
+ let tunnelReady = !proxy;
28
+ let proxyResponse = new Uint8Array(0);
23
29
  this.socket.on('connect', () => {
24
30
  if (this.readyState !== WA_READY_STATES.CONNECTING)
25
31
  return;
26
- this.readyState = WA_READY_STATES.OPEN;
27
- this.onopen?.({});
32
+ if (proxy) {
33
+ const authority = `${host}:${port}`;
34
+ const lines = [
35
+ `CONNECT ${authority} HTTP/1.1`,
36
+ `Host: ${authority}`,
37
+ 'Proxy-Connection: Keep-Alive'
38
+ ];
39
+ if (proxy.authorization)
40
+ lines.push(`Proxy-Authorization: ${proxy.authorization}`);
41
+ this.socket.write(TEXT_ENCODER.encode(`${lines.join('\r\n')}\r\n\r\n`));
42
+ return;
43
+ }
44
+ this.markOpen();
28
45
  });
29
46
  this.socket.on('data', (chunk) => {
30
- if (!this.onmessage || this.readyState !== WA_READY_STATES.OPEN)
47
+ if (!tunnelReady && proxy) {
48
+ proxyResponse = concatBytes([proxyResponse, chunk]);
49
+ const headerEnd = findHttpHeaderEnd(proxyResponse);
50
+ const scanned = headerEnd === -1 ? proxyResponse.byteLength : headerEnd;
51
+ if (scanned > MAX_PROXY_HEADER_BYTES) {
52
+ this.socket.destroy(new Error('WaMobileTcpSocket: proxy response headers too large'));
53
+ return;
54
+ }
55
+ if (headerEnd === -1) {
56
+ return;
57
+ }
58
+ const statusLine = TEXT_DECODER.decode(proxyResponse.subarray(0, headerEnd)).split('\r\n')[0];
59
+ if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
60
+ this.socket.destroy(new Error(`WaMobileTcpSocket: proxy CONNECT failed (${statusLine})`));
61
+ return;
62
+ }
63
+ const remaining = proxyResponse.subarray(headerEnd + 4);
64
+ proxyResponse = new Uint8Array(0);
65
+ tunnelReady = true;
66
+ this.markOpen();
67
+ if (remaining.byteLength > 0)
68
+ this.emitMessage(remaining);
31
69
  return;
32
- const copy = new Uint8Array(chunk.byteLength);
33
- copy.set(chunk);
34
- this.onmessage({ data: copy });
70
+ }
71
+ this.emitMessage(chunk);
35
72
  });
36
73
  this.socket.on('error', (err) => {
37
74
  this.closedClean = false;
@@ -53,6 +90,19 @@ export class WaMobileTcpSocket {
53
90
  });
54
91
  });
55
92
  }
93
+ markOpen() {
94
+ if (this.readyState !== WA_READY_STATES.CONNECTING)
95
+ return;
96
+ this.readyState = WA_READY_STATES.OPEN;
97
+ this.onopen?.({});
98
+ }
99
+ emitMessage(chunk) {
100
+ if (!this.onmessage || this.readyState !== WA_READY_STATES.OPEN)
101
+ return;
102
+ const copy = new Uint8Array(chunk.byteLength);
103
+ copy.set(chunk);
104
+ this.onmessage({ data: copy });
105
+ }
56
106
  send(data) {
57
107
  if (this.readyState !== WA_READY_STATES.OPEN) {
58
108
  throw new Error('WaMobileTcpSocket: send() called on non-OPEN socket');
@@ -85,6 +135,16 @@ export class WaMobileTcpSocket {
85
135
  this.forceCloseTimer.unref();
86
136
  }
87
137
  }
138
+ /** Cap on the CONNECT response headers buffered before the tunnel is rejected. */
139
+ const MAX_PROXY_HEADER_BYTES = 65536;
140
+ function findHttpHeaderEnd(bytes) {
141
+ for (let i = 3; i < bytes.byteLength; i += 1) {
142
+ if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) {
143
+ return i - 3;
144
+ }
145
+ }
146
+ return -1;
147
+ }
88
148
  function parseTcpUrl(url) {
89
149
  let work = url;
90
150
  if (work.startsWith('tcp://')) {
@@ -13,6 +13,20 @@ function parseAppVersion(version) {
13
13
  quaternary: at(3)
14
14
  };
15
15
  }
16
+ function distributionChannelId(channel) {
17
+ const { DistributionChannel } = proto.ClientPayload.UserAgent;
18
+ switch (channel) {
19
+ case 'website':
20
+ return DistributionChannel.WEBSITE;
21
+ case 'testflight':
22
+ return DistributionChannel.TESTFLIGHT;
23
+ case 'internal':
24
+ return DistributionChannel.INTERNAL;
25
+ case 'appstore':
26
+ case undefined:
27
+ return DistributionChannel.APPSTORE;
28
+ }
29
+ }
16
30
  /**
17
31
  * Builds the encoded {@link Proto.ClientPayload} bytes the WhatsApp Mobile
18
32
  * transport sends after the noise login handshake. Throws when
@@ -24,14 +38,18 @@ export function buildMobileLoginPayload(config) {
24
38
  }
25
39
  const info = config.deviceInfo;
26
40
  const version = parseAppVersion(info.appVersion);
27
- const userAgent = {
28
- platform: info.business
41
+ const isIos = info.os === 'ios';
42
+ const platform = isIos
43
+ ? info.business
44
+ ? proto.ClientPayload.UserAgent.Platform.SMB_IOS
45
+ : proto.ClientPayload.UserAgent.Platform.IOS
46
+ : info.business
29
47
  ? proto.ClientPayload.UserAgent.Platform.SMB_ANDROID
30
- : proto.ClientPayload.UserAgent.Platform.ANDROID,
48
+ : proto.ClientPayload.UserAgent.Platform.ANDROID;
49
+ const userAgent = {
50
+ platform,
31
51
  releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
32
52
  appVersion: version,
33
- mcc: info.mcc ?? '000',
34
- mnc: info.mnc ?? '000',
35
53
  osVersion: info.osVersion,
36
54
  manufacturer: info.manufacturer,
37
55
  device: info.device,
@@ -40,8 +58,10 @@ export function buildMobileLoginPayload(config) {
40
58
  localeLanguageIso6391: info.localeLanguageIso6391 ?? 'en',
41
59
  localeCountryIso31661Alpha2: info.localeCountryIso31661Alpha2 ?? 'US',
42
60
  deviceType: proto.ClientPayload.UserAgent.DeviceType.PHONE,
43
- deviceBoard: info.deviceBoard,
44
- deviceModelType: info.deviceModelType
61
+ deviceModelType: info.deviceModelType,
62
+ ...(isIos
63
+ ? { distributionChannel: distributionChannelId(info.distributionChannel) }
64
+ : { mcc: info.mcc ?? '000', mnc: info.mnc ?? '000', deviceBoard: info.deviceBoard })
45
65
  };
46
66
  return proto.ClientPayload.encode({
47
67
  passive: config.passive === true,
@@ -1,3 +1,4 @@
1
+ import { bytesToBase64, TEXT_ENCODER } from '../util/bytes.js';
1
2
  /** Type guard for an undici-style proxy dispatcher (has `dispatch` method). */
2
3
  export function isProxyDispatcher(value) {
3
4
  return (typeof value === 'object' &&
@@ -30,3 +31,85 @@ export function toProxyAgent(proxy) {
30
31
  }
31
32
  return proxy;
32
33
  }
34
+ const TCP_PROXY_HINT = 'socketOptions.proxy.ws must hold an http.Agent-style proxy pointing at an http: url (e.g. new HttpProxyAgent("http://host:port")) to tunnel raw TCP';
35
+ /**
36
+ * Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
37
+ * CONNECT.
38
+ *
39
+ * Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
40
+ * cannot honour throw instead of resolving to `undefined`, because dropping one
41
+ * silently would dial the destination directly – the opposite of what a
42
+ * deployment that pins its egress to a proxy asked for.
43
+ */
44
+ export function toTcpProxyEndpoint(proxy) {
45
+ if (!proxy) {
46
+ return undefined;
47
+ }
48
+ if (!isProxyAgent(proxy)) {
49
+ if (isProxyDispatcher(proxy)) {
50
+ throw new Error(`undici-style proxy dispatchers cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
51
+ }
52
+ throw new Error(`unsupported proxy transport – ${TCP_PROXY_HINT}`);
53
+ }
54
+ const url = readAgentProxyUrl(proxy);
55
+ if (url.protocol !== 'http:') {
56
+ throw new Error(`proxy protocol ${url.protocol} is not supported by the raw TCP tunnel – ${TCP_PROXY_HINT}`);
57
+ }
58
+ const username = decodeProxyUserInfo(url.username, 'username');
59
+ const password = decodeProxyUserInfo(url.password, 'password');
60
+ return {
61
+ hostname: url.hostname,
62
+ port: url.port ? Number(url.port) : 80,
63
+ authorization: username || password
64
+ ? `Basic ${bytesToBase64(TEXT_ENCODER.encode(`${username}:${password}`))}`
65
+ : undefined
66
+ };
67
+ }
68
+ /** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
69
+ export function assertTcpProxySupported(proxy) {
70
+ toTcpProxyEndpoint(proxy);
71
+ }
72
+ /**
73
+ * Decodes one userinfo component of a proxy url. `new URL()` keeps invalid
74
+ * percent escapes verbatim, so a credential holding a literal `%` would reach
75
+ * `decodeURIComponent` and throw a bare `URIError`. The value never reaches the
76
+ * message – only the field name does.
77
+ */
78
+ function decodeProxyUserInfo(value, field) {
79
+ try {
80
+ return decodeURIComponent(value);
81
+ }
82
+ catch {
83
+ throw new Error(`proxy url ${field} contains a malformed percent escape`);
84
+ }
85
+ }
86
+ /**
87
+ * Reads the proxy url an `http.Agent`-style proxy exposes. Every `*-proxy-agent`
88
+ * package keeps it on `.proxy`, but only the http/https ones store a `URL`
89
+ * there – `socks-proxy-agent` stores a parsed `{ host, port, type }` endpoint.
90
+ */
91
+ function readAgentProxyUrl(agent) {
92
+ const value = agent.proxy;
93
+ if (value instanceof URL) {
94
+ return value;
95
+ }
96
+ if (typeof value === 'string') {
97
+ try {
98
+ return new URL(value);
99
+ }
100
+ catch {
101
+ throw new Error(`proxy agent exposes an unparseable proxy url ${JSON.stringify(value)}`);
102
+ }
103
+ }
104
+ if (isSocksProxyEndpoint(value)) {
105
+ throw new Error(`socks proxy agents cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
106
+ }
107
+ throw new Error(`proxy agent exposes no proxy url – ${TCP_PROXY_HINT}`);
108
+ }
109
+ /** Matches the `{ host, port, type }` endpoint `socks-proxy-agent` keeps on `.proxy`. */
110
+ function isSocksProxyEndpoint(value) {
111
+ return (typeof value === 'object' &&
112
+ value !== null &&
113
+ typeof value.host === 'string' &&
114
+ typeof value.port === 'number');
115
+ }
@@ -180,7 +180,8 @@ function unwrapMessage(message) {
180
180
  msg.viewOnceMessageV2?.message ??
181
181
  msg.documentWithCaptionMessage?.message ??
182
182
  msg.groupStatusMessage?.message ??
183
- msg.groupStatusMessageV2?.message;
183
+ msg.groupStatusMessageV2?.message ??
184
+ msg.botForwardedMessage?.message;
184
185
  if (!inner)
185
186
  return msg;
186
187
  msg = inner;
@@ -228,6 +229,8 @@ function resolveMessageTypeAttrFrom(msg) {
228
229
  msg.pollResultSnapshotMessageV3 ||
229
230
  msg.templateButtonReplyMessage ||
230
231
  msg.messageHistoryNotice ||
232
+ msg.richResponseMessage ||
233
+ msg.botForwardedMessage ||
231
234
  msg.secretEncryptedMessage?.secretEncType ===
232
235
  _proto_1.proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT ||
233
236
  msg.secretEncryptedMessage?.secretEncType ===
@@ -16,7 +16,9 @@ export declare class WaMobileTcpSocket implements RawWebSocket {
16
16
  private closedReason;
17
17
  private closedClean;
18
18
  private forceCloseTimer;
19
- constructor(url: string, _protocols?: unknown, _options?: WaRawWebSocketInit);
19
+ constructor(url: string, _protocols?: unknown, options?: WaRawWebSocketInit);
20
+ private markOpen;
21
+ private emitMessage;
20
22
  send(data: string | ArrayBuffer | Uint8Array): void;
21
23
  close(code?: number, reason?: string): void;
22
24
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaMobileTcpSocketCtor = exports.WaMobileTcpSocket = void 0;
4
4
  const node_net_1 = require("node:net");
5
5
  const constants_1 = require("../../protocol/constants");
6
+ const proxy_1 = require("../proxy");
6
7
  const bytes_1 = require("../../util/bytes");
7
8
  /**
8
9
  * `RawWebSocket`-shaped adapter over a raw Node TCP socket. Used by the
@@ -10,7 +11,7 @@ const bytes_1 = require("../../util/bytes");
10
11
  * `tcp://host:port` URLs.
11
12
  */
12
13
  class WaMobileTcpSocket {
13
- constructor(url, _protocols, _options) {
14
+ constructor(url, _protocols, options) {
14
15
  this.binaryType = 'arraybuffer';
15
16
  this.readyState = constants_1.WA_READY_STATES.CONNECTING;
16
17
  this.onopen = null;
@@ -22,19 +23,55 @@ class WaMobileTcpSocket {
22
23
  this.closedClean = true;
23
24
  this.forceCloseTimer = null;
24
25
  const { host, port } = parseTcpUrl(url);
25
- this.socket = (0, node_net_1.connect)({ host, port });
26
+ const proxy = (0, proxy_1.toTcpProxyEndpoint)(options?.agent);
27
+ this.socket = proxy
28
+ ? (0, node_net_1.connect)({ host: proxy.hostname, port: proxy.port })
29
+ : (0, node_net_1.connect)({ host, port });
30
+ let tunnelReady = !proxy;
31
+ let proxyResponse = new Uint8Array(0);
26
32
  this.socket.on('connect', () => {
27
33
  if (this.readyState !== constants_1.WA_READY_STATES.CONNECTING)
28
34
  return;
29
- this.readyState = constants_1.WA_READY_STATES.OPEN;
30
- this.onopen?.({});
35
+ if (proxy) {
36
+ const authority = `${host}:${port}`;
37
+ const lines = [
38
+ `CONNECT ${authority} HTTP/1.1`,
39
+ `Host: ${authority}`,
40
+ 'Proxy-Connection: Keep-Alive'
41
+ ];
42
+ if (proxy.authorization)
43
+ lines.push(`Proxy-Authorization: ${proxy.authorization}`);
44
+ this.socket.write(bytes_1.TEXT_ENCODER.encode(`${lines.join('\r\n')}\r\n\r\n`));
45
+ return;
46
+ }
47
+ this.markOpen();
31
48
  });
32
49
  this.socket.on('data', (chunk) => {
33
- if (!this.onmessage || this.readyState !== constants_1.WA_READY_STATES.OPEN)
50
+ if (!tunnelReady && proxy) {
51
+ proxyResponse = (0, bytes_1.concatBytes)([proxyResponse, chunk]);
52
+ const headerEnd = findHttpHeaderEnd(proxyResponse);
53
+ const scanned = headerEnd === -1 ? proxyResponse.byteLength : headerEnd;
54
+ if (scanned > MAX_PROXY_HEADER_BYTES) {
55
+ this.socket.destroy(new Error('WaMobileTcpSocket: proxy response headers too large'));
56
+ return;
57
+ }
58
+ if (headerEnd === -1) {
59
+ return;
60
+ }
61
+ const statusLine = bytes_1.TEXT_DECODER.decode(proxyResponse.subarray(0, headerEnd)).split('\r\n')[0];
62
+ if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
63
+ this.socket.destroy(new Error(`WaMobileTcpSocket: proxy CONNECT failed (${statusLine})`));
64
+ return;
65
+ }
66
+ const remaining = proxyResponse.subarray(headerEnd + 4);
67
+ proxyResponse = new Uint8Array(0);
68
+ tunnelReady = true;
69
+ this.markOpen();
70
+ if (remaining.byteLength > 0)
71
+ this.emitMessage(remaining);
34
72
  return;
35
- const copy = new Uint8Array(chunk.byteLength);
36
- copy.set(chunk);
37
- this.onmessage({ data: copy });
73
+ }
74
+ this.emitMessage(chunk);
38
75
  });
39
76
  this.socket.on('error', (err) => {
40
77
  this.closedClean = false;
@@ -56,6 +93,19 @@ class WaMobileTcpSocket {
56
93
  });
57
94
  });
58
95
  }
96
+ markOpen() {
97
+ if (this.readyState !== constants_1.WA_READY_STATES.CONNECTING)
98
+ return;
99
+ this.readyState = constants_1.WA_READY_STATES.OPEN;
100
+ this.onopen?.({});
101
+ }
102
+ emitMessage(chunk) {
103
+ if (!this.onmessage || this.readyState !== constants_1.WA_READY_STATES.OPEN)
104
+ return;
105
+ const copy = new Uint8Array(chunk.byteLength);
106
+ copy.set(chunk);
107
+ this.onmessage({ data: copy });
108
+ }
59
109
  send(data) {
60
110
  if (this.readyState !== constants_1.WA_READY_STATES.OPEN) {
61
111
  throw new Error('WaMobileTcpSocket: send() called on non-OPEN socket');
@@ -89,6 +139,16 @@ class WaMobileTcpSocket {
89
139
  }
90
140
  }
91
141
  exports.WaMobileTcpSocket = WaMobileTcpSocket;
142
+ /** Cap on the CONNECT response headers buffered before the tunnel is rejected. */
143
+ const MAX_PROXY_HEADER_BYTES = 65536;
144
+ function findHttpHeaderEnd(bytes) {
145
+ for (let i = 3; i < bytes.byteLength; i += 1) {
146
+ if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) {
147
+ return i - 3;
148
+ }
149
+ }
150
+ return -1;
151
+ }
92
152
  function parseTcpUrl(url) {
93
153
  let work = url;
94
154
  if (work.startsWith('tcp://')) {
@@ -1,5 +1,25 @@
1
1
  import { type Proto } from '../../proto';
2
+ /**
3
+ * Distribution channel advertised by an iOS login (`UserAgent.distributionChannel`).
4
+ * iOS-only – Android leaves the field unset. `appstore` is the normal
5
+ * production install; `testflight`/`internal` mark beta builds.
6
+ */
7
+ export type WaMobileDistributionChannel = 'appstore' | 'website' | 'testflight' | 'internal';
2
8
  export interface WaMobileTransportDeviceInfo {
9
+ /**
10
+ * Operating system the login payload impersonates. `android` (the default,
11
+ * for backwards compatibility) advertises the `ANDROID`/`SMB_ANDROID`
12
+ * platform and fills the Android-only `mcc`/`mnc`/`deviceBoard` fields.
13
+ * `ios` advertises `IOS`/`SMB_IOS`, omits those Android-only fields (the
14
+ * real iPhone client never sends them) and carries `distributionChannel`
15
+ * instead.
16
+ *
17
+ * Field mapping differs by OS and must match the real client:
18
+ * - **Android** `device` = a device codename (e.g. `moto_g52`).
19
+ * - **iOS** `device` = the marketing name (e.g. `iPhone 15 Pro`) while
20
+ * {@link deviceModelType} carries the raw machine id (`iPhone16,1`).
21
+ */
22
+ readonly os?: 'android' | 'ios';
3
23
  readonly manufacturer: string;
4
24
  readonly device: string;
5
25
  readonly osVersion: string;
@@ -12,6 +32,8 @@ export interface WaMobileTransportDeviceInfo {
12
32
  readonly phoneId?: string;
13
33
  readonly deviceBoard?: string;
14
34
  readonly deviceModelType?: string;
35
+ /** iOS distribution channel; ignored for Android. Defaults to `appstore`. */
36
+ readonly distributionChannel?: WaMobileDistributionChannel;
15
37
  readonly business?: boolean;
16
38
  }
17
39
  export interface WaMobileLoginPayloadConfig {
@@ -16,6 +16,20 @@ function parseAppVersion(version) {
16
16
  quaternary: at(3)
17
17
  };
18
18
  }
19
+ function distributionChannelId(channel) {
20
+ const { DistributionChannel } = _proto_1.proto.ClientPayload.UserAgent;
21
+ switch (channel) {
22
+ case 'website':
23
+ return DistributionChannel.WEBSITE;
24
+ case 'testflight':
25
+ return DistributionChannel.TESTFLIGHT;
26
+ case 'internal':
27
+ return DistributionChannel.INTERNAL;
28
+ case 'appstore':
29
+ case undefined:
30
+ return DistributionChannel.APPSTORE;
31
+ }
32
+ }
19
33
  /**
20
34
  * Builds the encoded {@link Proto.ClientPayload} bytes the WhatsApp Mobile
21
35
  * transport sends after the noise login handshake. Throws when
@@ -27,14 +41,18 @@ function buildMobileLoginPayload(config) {
27
41
  }
28
42
  const info = config.deviceInfo;
29
43
  const version = parseAppVersion(info.appVersion);
30
- const userAgent = {
31
- platform: info.business
44
+ const isIos = info.os === 'ios';
45
+ const platform = isIos
46
+ ? info.business
47
+ ? _proto_1.proto.ClientPayload.UserAgent.Platform.SMB_IOS
48
+ : _proto_1.proto.ClientPayload.UserAgent.Platform.IOS
49
+ : info.business
32
50
  ? _proto_1.proto.ClientPayload.UserAgent.Platform.SMB_ANDROID
33
- : _proto_1.proto.ClientPayload.UserAgent.Platform.ANDROID,
51
+ : _proto_1.proto.ClientPayload.UserAgent.Platform.ANDROID;
52
+ const userAgent = {
53
+ platform,
34
54
  releaseChannel: _proto_1.proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
35
55
  appVersion: version,
36
- mcc: info.mcc ?? '000',
37
- mnc: info.mnc ?? '000',
38
56
  osVersion: info.osVersion,
39
57
  manufacturer: info.manufacturer,
40
58
  device: info.device,
@@ -43,8 +61,10 @@ function buildMobileLoginPayload(config) {
43
61
  localeLanguageIso6391: info.localeLanguageIso6391 ?? 'en',
44
62
  localeCountryIso31661Alpha2: info.localeCountryIso31661Alpha2 ?? 'US',
45
63
  deviceType: _proto_1.proto.ClientPayload.UserAgent.DeviceType.PHONE,
46
- deviceBoard: info.deviceBoard,
47
- deviceModelType: info.deviceModelType
64
+ deviceModelType: info.deviceModelType,
65
+ ...(isIos
66
+ ? { distributionChannel: distributionChannelId(info.distributionChannel) }
67
+ : { mcc: info.mcc ?? '000', mnc: info.mnc ?? '000', deviceBoard: info.deviceBoard })
48
68
  };
49
69
  return _proto_1.proto.ClientPayload.encode({
50
70
  passive: config.passive === true,
@@ -9,3 +9,27 @@ export declare function isProxyTransport(value: unknown): value is WaProxyTransp
9
9
  export declare function toProxyDispatcher(proxy: WaProxyTransport | undefined): WaProxyDispatcher | undefined;
10
10
  /** Narrows `proxy` to {@link WaProxyAgent} or returns `undefined`. */
11
11
  export declare function toProxyAgent(proxy: WaProxyTransport | undefined): WaProxyAgent | undefined;
12
+ /**
13
+ * HTTP CONNECT endpoint a raw TCP transport dials to reach its destination.
14
+ *
15
+ * @sensitive Contains proxy credentials (`authorization`). Never log, serialize
16
+ * via `JSON.stringify`, or transmit unencrypted. Persist with encryption-at-rest.
17
+ */
18
+ export interface WaTcpProxyEndpoint {
19
+ readonly hostname: string;
20
+ readonly port: number;
21
+ /** Ready-to-send `Proxy-Authorization` value when the proxy url carries credentials. */
22
+ readonly authorization?: string;
23
+ }
24
+ /**
25
+ * Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
26
+ * CONNECT.
27
+ *
28
+ * Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
29
+ * cannot honour throw instead of resolving to `undefined`, because dropping one
30
+ * silently would dial the destination directly – the opposite of what a
31
+ * deployment that pins its egress to a proxy asked for.
32
+ */
33
+ export declare function toTcpProxyEndpoint(proxy: WaProxyTransport | undefined): WaTcpProxyEndpoint | undefined;
34
+ /** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
35
+ export declare function assertTcpProxySupported(proxy: WaProxyTransport | undefined): void;
@@ -5,6 +5,9 @@ exports.isProxyAgent = isProxyAgent;
5
5
  exports.isProxyTransport = isProxyTransport;
6
6
  exports.toProxyDispatcher = toProxyDispatcher;
7
7
  exports.toProxyAgent = toProxyAgent;
8
+ exports.toTcpProxyEndpoint = toTcpProxyEndpoint;
9
+ exports.assertTcpProxySupported = assertTcpProxySupported;
10
+ const bytes_1 = require("../util/bytes");
8
11
  /** Type guard for an undici-style proxy dispatcher (has `dispatch` method). */
9
12
  function isProxyDispatcher(value) {
10
13
  return (typeof value === 'object' &&
@@ -37,3 +40,85 @@ function toProxyAgent(proxy) {
37
40
  }
38
41
  return proxy;
39
42
  }
43
+ const TCP_PROXY_HINT = 'socketOptions.proxy.ws must hold an http.Agent-style proxy pointing at an http: url (e.g. new HttpProxyAgent("http://host:port")) to tunnel raw TCP';
44
+ /**
45
+ * Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
46
+ * CONNECT.
47
+ *
48
+ * Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
49
+ * cannot honour throw instead of resolving to `undefined`, because dropping one
50
+ * silently would dial the destination directly – the opposite of what a
51
+ * deployment that pins its egress to a proxy asked for.
52
+ */
53
+ function toTcpProxyEndpoint(proxy) {
54
+ if (!proxy) {
55
+ return undefined;
56
+ }
57
+ if (!isProxyAgent(proxy)) {
58
+ if (isProxyDispatcher(proxy)) {
59
+ throw new Error(`undici-style proxy dispatchers cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
60
+ }
61
+ throw new Error(`unsupported proxy transport – ${TCP_PROXY_HINT}`);
62
+ }
63
+ const url = readAgentProxyUrl(proxy);
64
+ if (url.protocol !== 'http:') {
65
+ throw new Error(`proxy protocol ${url.protocol} is not supported by the raw TCP tunnel – ${TCP_PROXY_HINT}`);
66
+ }
67
+ const username = decodeProxyUserInfo(url.username, 'username');
68
+ const password = decodeProxyUserInfo(url.password, 'password');
69
+ return {
70
+ hostname: url.hostname,
71
+ port: url.port ? Number(url.port) : 80,
72
+ authorization: username || password
73
+ ? `Basic ${(0, bytes_1.bytesToBase64)(bytes_1.TEXT_ENCODER.encode(`${username}:${password}`))}`
74
+ : undefined
75
+ };
76
+ }
77
+ /** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
78
+ function assertTcpProxySupported(proxy) {
79
+ toTcpProxyEndpoint(proxy);
80
+ }
81
+ /**
82
+ * Decodes one userinfo component of a proxy url. `new URL()` keeps invalid
83
+ * percent escapes verbatim, so a credential holding a literal `%` would reach
84
+ * `decodeURIComponent` and throw a bare `URIError`. The value never reaches the
85
+ * message – only the field name does.
86
+ */
87
+ function decodeProxyUserInfo(value, field) {
88
+ try {
89
+ return decodeURIComponent(value);
90
+ }
91
+ catch {
92
+ throw new Error(`proxy url ${field} contains a malformed percent escape`);
93
+ }
94
+ }
95
+ /**
96
+ * Reads the proxy url an `http.Agent`-style proxy exposes. Every `*-proxy-agent`
97
+ * package keeps it on `.proxy`, but only the http/https ones store a `URL`
98
+ * there – `socks-proxy-agent` stores a parsed `{ host, port, type }` endpoint.
99
+ */
100
+ function readAgentProxyUrl(agent) {
101
+ const value = agent.proxy;
102
+ if (value instanceof URL) {
103
+ return value;
104
+ }
105
+ if (typeof value === 'string') {
106
+ try {
107
+ return new URL(value);
108
+ }
109
+ catch {
110
+ throw new Error(`proxy agent exposes an unparseable proxy url ${JSON.stringify(value)}`);
111
+ }
112
+ }
113
+ if (isSocksProxyEndpoint(value)) {
114
+ throw new Error(`socks proxy agents cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
115
+ }
116
+ throw new Error(`proxy agent exposes no proxy url – ${TCP_PROXY_HINT}`);
117
+ }
118
+ /** Matches the `{ host, port, type }` endpoint `socks-proxy-agent` keeps on `.proxy`. */
119
+ function isSocksProxyEndpoint(value) {
120
+ return (typeof value === 'object' &&
121
+ value !== null &&
122
+ typeof value.host === 'string' &&
123
+ typeof value.port === 'number');
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "High-performance WhatsApp Web TypeScript library",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",