whalibmob 5.11.0 → 5.12.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/.env.example CHANGED
@@ -80,6 +80,15 @@
80
80
  # WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
81
81
 
82
82
 
83
+ # ─── Registration funnel telemetry ───────────────────────────────────────────
84
+ # Registration reports the screens it passes through to WhatsApp's /client_log,
85
+ # the way the phone clients do. It is fire-and-forget — every failure is
86
+ # swallowed and it can never take a registration down.
87
+ #
88
+ # Set to 0 to send none of it.
89
+ # WA_FUNNEL_LOG=0
90
+
91
+
83
92
  # ─── Frida attestation bridge ────────────────────────────────────────────────
84
93
  # Host running the Frida attestation helper (see the frida/ directory).
85
94
  # Leave unset to disable the bridge.
package/README.md CHANGED
@@ -154,6 +154,7 @@ npm install -g whalibmob
154
154
  - [Knowing Which Mode You Are In](#knowing-which-mode-you-are-in)
155
155
  - [Two Sessions on One Number](#two-sessions-on-one-number)
156
156
  - [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under)
157
+ - [When Registration Is Refused for Consent](#when-registration-is-refused-for-consent)
157
158
  - [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
158
159
  - [Saving & Restoring Sessions](#saving--restoring-sessions)
159
160
  - [Signal Store Utilities](#signal-store-utilities)
@@ -306,6 +307,30 @@ if (result.status === 'ok') {
306
307
  }
307
308
  ```
308
309
 
310
+ **When the code is not the end of it.** The server can answer a submitted code with a CAPTCHA, or with a demand for the account's two-step verification PIN. Neither is something the library can work out on its own, so both come back to you through optional handlers:
311
+
312
+ ```js
313
+ const result = await verifyCode(store, '123456', {
314
+ // image and audio are Buffers, or null when that variant was not sent.
315
+ // Return the answer, or null to give up.
316
+ async solveCaptcha({ image, audio }) {
317
+ fs.writeFileSync('captcha.png', image)
318
+ return await askTheUser()
319
+ },
320
+
321
+ // The six-digit PIN set on the phone under
322
+ // Settings → Account → Two-step verification.
323
+ async twoFactorPin() {
324
+ return await askTheUser()
325
+ }
326
+ })
327
+ ```
328
+
329
+ Both are optional and both keep working when omitted — you get an error naming what was asked for instead of a silent failure, with the CAPTCHA blobs attached as `err.captcha`. A wrong CAPTCHA answer is replied to with another one, so `solveCaptcha` may be called several times. The CLI prompts for both, writing the image to a temp file first.
330
+
331
+ > [!NOTE]
332
+ > Registration reports the screens it passes through to WhatsApp's `/client_log`, the way the phone clients do — a client that registers in total silence does something no real installation does. It is fire-and-forget and every failure is swallowed, so it can never take a registration down. Set `WA_FUNNEL_LOG=0` to send none of it.
333
+
309
334
  ### Device Attestation with Frida (optional)
310
335
 
311
336
  WhatsApp's registration servers score every `/code` and `/register` request on how
@@ -863,6 +888,27 @@ if (probe.mismatch) {
863
888
  > [!NOTE]
864
889
  > The check costs nothing when things work — it runs only after a login has already been refused.
865
890
 
891
+ ## When Registration Is Refused for Consent
892
+
893
+ Some numbers come back from `/register` like this:
894
+
895
+ ```json
896
+ { "login": "557176034186", "pending": "app_store_age", "reason": "consent", "status": "fail" }
897
+ ```
898
+
899
+ The code was not refused and the account was found — WhatsApp is asking for an age signal that only a real app-store install carries, and will not finish without it. Brazilian numbers are where this turns up in practice.
900
+
901
+ The first thing to try is the Android device profile. The iOS registration request carries six fields and none of them says anything about consent, terms or age; the Android one carries `tos_version`, `education_screen_displayed` and `clicked_education_link`.
902
+
903
+ ```sh
904
+ WA_OS=android wa registration --code 5571976034186
905
+ ```
906
+
907
+ If that is refused too, the number has to go through the real app once, on a phone, before it can be registered here.
908
+
909
+ > [!NOTE]
910
+ > The `login` field in that reply is worth reading. Brazilian mobiles gained a ninth digit that WhatsApp never adopted, so `+5571976034186` is filed as `+557176034186`. whalibmob adopts the server's form automatically on a successful registration and saves the session under it — the digit difference is not itself the failure.
911
+
866
912
  ## Routing Registration Through a Proxy
867
913
 
868
914
  Registration is the part of the protocol most likely to be refused from a datacenter IP. If you are seeing security blocks or an undeterminable number status, route it through a SOCKS proxy — Tor, or a residential provider.
package/cli.js CHANGED
@@ -833,6 +833,49 @@ function askLoginMethod(phone) {
833
833
  });
834
834
  }
835
835
 
836
+ // Handlers for the two things a registration can stop and ask for.
837
+ //
838
+ // The server can answer a submitted code with a captcha, or with a demand for
839
+ // the account's two-step PIN. Neither is something the library can work out, so
840
+ // both come back to whoever is driving it — here, the person at the terminal.
841
+ function registrationPrompts() {
842
+ const prompt = (question) => new Promise((resolve) => {
843
+ if (!process.stdin.isTTY) return resolve(null);
844
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
845
+ rl.question(question, (answer) => { rl.close(); resolve(String(answer).trim()); });
846
+ });
847
+
848
+ return {
849
+ async solveCaptcha({ image, audio }) {
850
+ out('');
851
+ hr();
852
+ out(' WhatsApp is asking for a CAPTCHA before it will finish this registration.');
853
+ // Written out rather than described: it is a picture, and there is
854
+ // nothing useful to say about it in a terminal.
855
+ for (const [what, buf, ext] of [['image', image, 'png'], ['audio', audio, 'mp3']]) {
856
+ if (!buf || !buf.length) continue;
857
+ const file = path.join(os.tmpdir(), 'whalibmob-captcha-' + Date.now() + '.' + ext);
858
+ try {
859
+ fs.writeFileSync(file, buf);
860
+ out(' ' + what + ' saved to ' + file + ' (' + buf.length + ' bytes)');
861
+ } catch (e) {
862
+ out(' could not save the ' + what + ': ' + e.message);
863
+ }
864
+ }
865
+ hr();
866
+ const answer = await prompt(' what does it say? ');
867
+ return answer || null;
868
+ },
869
+
870
+ async twoFactorPin() {
871
+ out('');
872
+ out(' this number has two-step verification switched on');
873
+ const pin = await prompt(' six-digit PIN: ');
874
+ return pin || null;
875
+ }
876
+ };
877
+ }
878
+
836
879
  // Link as a companion device.
837
880
  //
838
881
  // Connect first, then ask for the code: the request rides on the encrypted
@@ -2021,7 +2064,7 @@ async function handleLine(line) {
2021
2064
  const file = path.join(_sessDir, `${ph}.json`);
2022
2065
  const store = loadStore(file) || initAuthCreds(ph);
2023
2066
  out('verifying...');
2024
- const r = await verifyCode(store, code);
2067
+ const r = await verifyCode(store, code, registrationPrompts());
2025
2068
  if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
2026
2069
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2027
2070
  const finalStore = r.store || store;
@@ -2419,7 +2462,7 @@ async function main() {
2419
2462
  const store = loadStore(file) || initAuthCreds(ph);
2420
2463
  out('verifying code for +' + ph + '...');
2421
2464
  try {
2422
- const r = await verifyCode(store, code);
2465
+ const r = await verifyCode(store, code, registrationPrompts());
2423
2466
  if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
2424
2467
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2425
2468
  const finalStore = r.store || store;
@@ -6,6 +6,7 @@ const tls = require('tls');
6
6
  const curveJs = require('curve25519-js');
7
7
  const { getDeviceConfig } = require('./DeviceConfig');
8
8
  const attestation = require('./Attestation');
9
+ const { dbg: _whaDbg } = require('./logger');
9
10
 
10
11
  // ---------- SOCKS4 / SOCKS5 / Tor support ----------
11
12
  //
@@ -136,51 +137,144 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
136
137
  `Content-Length: ${Buffer.byteLength(body)}`
137
138
  ];
138
139
  if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
139
- // close, not keep-alive: the response is read by waiting for the server to
140
- // end the stream. Asking it to keep the connection open means that never
141
- // happens and the request hangs until something else times out.
140
+ // Asked for, but not relied on: the server answers and then keeps the
141
+ // connection open anyway. The response is finished by its own length, not by
142
+ // the socket closing.
142
143
  reqLines.push(`Connection: close`, '', body);
143
144
  const req = reqLines.join('\r\n');
144
145
 
145
146
  tlsSocket.write(req);
146
147
 
147
- const chunks = [];
148
- await new Promise((res, rej) => {
148
+ const { status, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
149
+ const bodyStr = bodyBuf.toString('utf8');
150
+
151
+ if (status !== 200) throw new Error(`HTTP ${status} ${path}: ${bodyStr}`);
152
+ try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
153
+ }
154
+
155
+ // A response is over when its own length says so, not when the socket shuts.
156
+ //
157
+ // This is the part `https.request` does for you and a hand-rolled socket does
158
+ // not. WhatsApp answers a registration request in well under a second and then
159
+ // leaves the connection open — `Connection: close` in the request does not
160
+ // change that. Waiting for end or close therefore waited for something that
161
+ // never came, and every proxied registration died on the read timeout with the
162
+ // reply already sitting in the buffer.
163
+ //
164
+ // So: parse as the bytes arrive, and stop at the end of the body — the byte
165
+ // count in Content-Length, or the terminating zero-length chunk. end and close
166
+ // stay as a fallback, for a reply that declares neither and can only be
167
+ // delimited by the connection itself.
168
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
169
+
170
+ function readHttpResponse(socket, label, timeoutMs) {
171
+ return new Promise((resolve, reject) => {
172
+ let raw = Buffer.alloc(0);
173
+ let done = false;
174
+
149
175
  const timer = setTimeout(() => {
150
- tlsSocket.destroy();
151
- rej(new Error('SOCKS proxy timed out reading ' + path));
152
- }, 30000);
153
- const finish = (fn, arg) => { clearTimeout(timer); fn(arg); };
154
- tlsSocket.on('data', d => chunks.push(d));
155
- tlsSocket.on('end', () => finish(res));
156
- tlsSocket.on('close', () => finish(res));
157
- tlsSocket.on('error', e => finish(rej, e));
176
+ settle(reject, new Error('SOCKS proxy timed out reading ' + label));
177
+ }, timeoutMs || 30000);
178
+
179
+ function settle(fn, arg) {
180
+ if (done) return;
181
+ done = true;
182
+ clearTimeout(timer);
183
+ socket.removeListener('data', onData);
184
+ socket.removeListener('end', onEnd);
185
+ socket.removeListener('close', onEnd);
186
+ socket.removeListener('error', onError);
187
+ socket.destroy();
188
+ fn(arg);
189
+ }
190
+
191
+ function onData(chunk) {
192
+ raw = raw.length ? Buffer.concat([raw, chunk]) : chunk;
193
+ if (raw.length > MAX_RESPONSE_BYTES) {
194
+ return settle(reject, new Error('response too large reading ' + label));
195
+ }
196
+ const parsed = parseHttpResponse(raw, false);
197
+ if (parsed) settle(resolve, parsed);
198
+ }
199
+
200
+ // The connection did close after all — then whatever arrived is the whole
201
+ // reply, even without a declared length.
202
+ function onEnd() {
203
+ const parsed = parseHttpResponse(raw, true);
204
+ if (parsed) return settle(resolve, parsed);
205
+ settle(reject, new Error('connection closed mid-response reading ' + label));
206
+ }
207
+
208
+ function onError(err) { settle(reject, err); }
209
+
210
+ socket.on('data', onData);
211
+ socket.on('end', onEnd);
212
+ socket.on('close', onEnd);
213
+ socket.on('error', onError);
158
214
  });
215
+ }
159
216
 
160
- const raw = Buffer.concat(chunks);
161
- const rawStr = raw.toString('utf8');
162
- const headerEnd = rawStr.indexOf('\r\n\r\n');
163
- const headerPart = rawStr.slice(0, headerEnd);
164
- const httpStatus = parseInt(rawStr.split(' ')[1]);
165
- let bodyStr = rawStr.slice(headerEnd + 4);
166
-
167
- // Handle chunked transfer encoding
168
- if (/transfer-encoding:\s*chunked/i.test(headerPart)) {
169
- let decoded = '';
170
- let pos = 0;
171
- while (pos < bodyStr.length) {
172
- const lineEnd = bodyStr.indexOf('\r\n', pos);
173
- if (lineEnd === -1) break;
174
- const chunkSize = parseInt(bodyStr.slice(pos, lineEnd), 16);
175
- if (!chunkSize) break;
176
- decoded += bodyStr.slice(lineEnd + 2, lineEnd + 2 + chunkSize);
177
- pos = lineEnd + 2 + chunkSize + 2;
217
+ /**
218
+ * Parse what has arrived so far.
219
+ *
220
+ * Returns { status, headers, body } once the response is complete, or null
221
+ * while it is still short. With `atEof` the caller is telling us no more bytes
222
+ * are coming, which is itself a valid way for a body to end.
223
+ */
224
+ function parseHttpResponse(raw, atEof) {
225
+ const headerEnd = raw.indexOf('\r\n\r\n');
226
+ if (headerEnd < 0) return null;
227
+
228
+ // Headers are latin1 by definition; only the body may be UTF-8.
229
+ const headers = raw.slice(0, headerEnd).toString('latin1');
230
+ const status = parseInt(headers.split(' ')[1], 10);
231
+ const bodyStart = headerEnd + 4;
232
+
233
+ if (/^transfer-encoding:[ \t]*chunked/im.test(headers)) {
234
+ const decoded = decodeChunkedBody(raw.slice(bodyStart));
235
+ if (decoded) return { status, headers, body: decoded };
236
+ return atEof ? { status, headers, body: Buffer.alloc(0) } : null;
237
+ }
238
+
239
+ const match = /^content-length:[ \t]*(\d+)/im.exec(headers);
240
+ if (match) {
241
+ const length = parseInt(match[1], 10);
242
+ // Counted in bytes, which is why this works on the buffer and not on a
243
+ // decoded string — one accented character in an error message is two bytes
244
+ // and would leave the check one short forever.
245
+ if (raw.length - bodyStart >= length) {
246
+ return { status, headers, body: raw.slice(bodyStart, bodyStart + length) };
178
247
  }
179
- bodyStr = decoded;
248
+ return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
180
249
  }
181
250
 
182
- if (httpStatus !== 200) throw new Error(`HTTP ${httpStatus} ${path}: ${bodyStr}`);
183
- try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
251
+ // These two carry no body at all, whatever else the headers say.
252
+ if (status === 204 || status === 304) {
253
+ return { status, headers, body: Buffer.alloc(0) };
254
+ }
255
+
256
+ // No length anywhere: the body runs to the end of the connection.
257
+ return atEof ? { status, headers, body: raw.slice(bodyStart) } : null;
258
+ }
259
+
260
+ /** Decode a chunked body, or null while the terminating chunk is still missing. */
261
+ function decodeChunkedBody(buf) {
262
+ const parts = [];
263
+ let pos = 0;
264
+
265
+ for (;;) {
266
+ const lineEnd = buf.indexOf('\r\n', pos);
267
+ if (lineEnd < 0) return null;
268
+ // A size line may carry extensions after a semicolon; parseInt stops there.
269
+ const size = parseInt(buf.slice(pos, lineEnd).toString('latin1'), 16);
270
+ if (!Number.isFinite(size) || size < 0) return null;
271
+ if (size === 0) return Buffer.concat(parts); // the terminating chunk
272
+ const start = lineEnd + 2;
273
+ const end = start + size;
274
+ if (buf.length < end + 2) return null; // chunk still arriving
275
+ parts.push(buf.slice(start, end));
276
+ pos = end + 2;
277
+ }
184
278
  }
185
279
  const {
186
280
  REGISTRATION_ENDPOINT,
@@ -651,6 +745,18 @@ function httpPost(path, body, waVersion, authHeader) {
651
745
 
652
746
  async function sendRequest(path, store, waVersion, useToken, extraPairs) {
653
747
  const plaintext = await buildPayload(store, waVersion, useToken, extraPairs);
748
+ return sendEncrypted(path, plaintext, store, waVersion);
749
+ }
750
+
751
+ /**
752
+ * Encrypt an already-built form body and post it.
753
+ *
754
+ * The attested endpoints reach this through sendRequest, which assembles the
755
+ * shared registration fields first. The funnel endpoints build their own much
756
+ * shorter body and come straight here — the native client sends no registration
757
+ * fields with a client log, only the identifiers and the screen names.
758
+ */
759
+ async function sendEncrypted(path, plaintext, store, waVersion) {
654
760
  const enc = encryptPayload(plaintext); // base64url ENC payload (no prefix)
655
761
  let body = 'ENC=' + enc;
656
762
 
@@ -667,6 +773,243 @@ async function sendRequest(path, store, waVersion, useToken, extraPairs) {
667
773
  return httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
668
774
  }
669
775
 
776
+ // ---------- Funnel telemetry ----------
777
+ //
778
+ // The native client reports every screen it moves through — the number entry,
779
+ // the code request, the code submission, a captcha appearing, a PIN prompt —
780
+ // to /client_log, and one session-start event to /pre_pn_client_log before the
781
+ // number is even known. A client that registers without any of that is doing
782
+ // something no real installation does.
783
+ //
784
+ // It is fire-and-forget by design. Every failure is swallowed: telemetry that
785
+ // can abort a registration is worse than no telemetry. Set WA_FUNNEL_LOG=0 to
786
+ // send none of it.
787
+
788
+ function funnelEnabled() {
789
+ return process.env.WA_FUNNEL_LOG !== '0';
790
+ }
791
+
792
+ // The screen a verification event belongs to, named after the method that was
793
+ // asked for — verify_sms, verify_voice. Before any request there is only the
794
+ // number entry screen.
795
+ function currentVerifyScreen(store) {
796
+ return store._lastRequestedMethod ? 'verify_' + store._lastRequestedMethod : 'enter_number';
797
+ }
798
+
799
+ async function sendFunnelLog(store, waVersion, currentScreen, actionTaken, eventName) {
800
+ if (!funnelEnabled()) return;
801
+ try {
802
+ const { cc, national } = parsePhone(store.phoneNumber);
803
+ const device = store.device || getDeviceConfig();
804
+ const fdid = device.os === 'android'
805
+ ? store.fdid.toLowerCase()
806
+ : store.fdid.toUpperCase();
807
+
808
+ const body = buildForm([
809
+ 'cc', cc,
810
+ 'in', national,
811
+ 'lg', 'en',
812
+ 'lc', 'US',
813
+ 'expid', toBase64Url(store.deviceId),
814
+ 'fdid', fdid,
815
+ 'id', toUrlHex(store.identityId),
816
+ 'current_screen', currentScreen,
817
+ 'previous_screen', store._funnelPreviousScreen || '',
818
+ 'action_taken', actionTaken,
819
+ 'event_name', eventName
820
+ ], null);
821
+
822
+ await sendEncrypted('/client_log', body, store, waVersion);
823
+ store._funnelPreviousScreen = currentScreen;
824
+ } catch (err) {
825
+ _whaDbg('[DBG] REG funnel log failed (' + currentScreen + '/' + actionTaken + '): ' +
826
+ (err && err.message));
827
+ }
828
+ }
829
+
830
+ // The one event that fires before a phone number is committed, so it carries
831
+ // neither cc nor in.
832
+ async function sendPrePnFunnelLog(store, waVersion, actionTaken, eventName) {
833
+ if (!funnelEnabled()) return;
834
+ try {
835
+ const device = store.device || getDeviceConfig();
836
+ const fdid = device.os === 'android'
837
+ ? store.fdid.toLowerCase()
838
+ : store.fdid.toUpperCase();
839
+
840
+ const body = buildForm([
841
+ 'lg', 'en',
842
+ 'lc', 'US',
843
+ 'expid', toBase64Url(store.deviceId),
844
+ 'fdid', fdid,
845
+ 'id', toUrlHex(store.identityId),
846
+ 'current_screen', 'enter_number',
847
+ 'previous_screen', '',
848
+ 'action_taken', actionTaken,
849
+ 'event_name', eventName
850
+ ], null);
851
+
852
+ await sendEncrypted('/pre_pn_client_log', body, store, waVersion);
853
+ } catch (err) {
854
+ _whaDbg('[DBG] REG pre-pn funnel log failed: ' + (err && err.message));
855
+ }
856
+ }
857
+
858
+ // ---------- Challenge and two-factor ----------
859
+
860
+ /** A captcha is a reply carrying an image or an audio blob to be solved. */
861
+ function hasChallenge(response) {
862
+ if (!response) return false;
863
+ return !!((response.image_blob && String(response.image_blob).length) ||
864
+ (response.audio_blob && String(response.audio_blob).length));
865
+ }
866
+
867
+ /** Three spellings of the same demand, all of them still in use. */
868
+ function is2FARequired(response) {
869
+ const reason = response && response.reason;
870
+ if (!reason) return false;
871
+ const r = String(reason).toLowerCase();
872
+ return r === '2fa_required' || r === 'security_code' || r === 'two_factor_required';
873
+ }
874
+
875
+ /**
876
+ * Decode a blob defensively.
877
+ *
878
+ * The server has been seen to switch between the standard and URL-safe base64
879
+ * alphabets between releases, so both are tried before giving up.
880
+ */
881
+ function decodeOrNull(b64) {
882
+ if (!b64) return null;
883
+ const str = String(b64);
884
+ if (!str.length) return null;
885
+ for (const alphabet of ['base64', 'base64url']) {
886
+ try {
887
+ const out = Buffer.from(str, alphabet);
888
+ if (out.length) return out;
889
+ } catch (_) {}
890
+ }
891
+ return null;
892
+ }
893
+
894
+ function isSuccessful(status) {
895
+ return status === 'ok' || status === 'sent' || status === 'verified';
896
+ }
897
+
898
+ function normalizeCodeResult(code) {
899
+ return String(code == null ? '' : code).replace(/[\s\-]/g, '').replace(/\D/g, '');
900
+ }
901
+
902
+ // Take the digits the server filed the account under. Brazilian mobiles are the
903
+ // standing example: they gained a ninth digit WhatsApp never adopted, and a
904
+ // session saved under the typed number sends a username on every connection
905
+ // that matches no registration.
906
+ function adoptCanonicalNumber(store, result) {
907
+ const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
908
+ if (canonical && canonical !== String(store.phoneNumber)) {
909
+ result.canonicalPhoneNumber = canonical;
910
+ result.typedPhoneNumber = String(store.phoneNumber);
911
+ store.phoneNumber = canonical;
912
+ }
913
+ return result;
914
+ }
915
+
916
+ /**
917
+ * Answer a captcha, and keep answering while the server keeps asking.
918
+ *
919
+ * A wrong answer is replied to with another challenge rather than a refusal,
920
+ * which is why this loops. Solving needs a caller that can show the image to
921
+ * somebody: without one there is nothing to do but say so, and hand the blobs
922
+ * over on the error so it can be dealt with elsewhere.
923
+ */
924
+ async function handleChallenge(store, waVersion, initial, opts) {
925
+ const screen = currentVerifyScreen(store);
926
+ await sendFunnelLog(store, waVersion, screen, 'challenge_shown', 'captcha_shown');
927
+
928
+ let response = initial;
929
+ for (;;) {
930
+ const image = decodeOrNull(response.image_blob);
931
+ const audio = decodeOrNull(response.audio_blob);
932
+
933
+ let answer = null;
934
+ if (typeof opts.solveCaptcha === 'function') {
935
+ answer = await opts.solveCaptcha({ image, audio, response });
936
+ }
937
+ if (!answer) {
938
+ await sendFunnelLog(store, waVersion, screen, 'challenge_abandoned', 'captcha_abandoned');
939
+ const err = new Error(
940
+ 'Registration needs a CAPTCHA solved' +
941
+ (typeof opts.solveCaptcha === 'function'
942
+ ? ', and the solveCaptcha handler returned nothing.'
943
+ : ', and no solveCaptcha handler was given.\n' +
944
+ ' Pass one to verifyCode: verifyCode(store, code, { solveCaptcha: async ({ image, audio }) => "…" }).\n' +
945
+ ' image and audio are Buffers, or null when that variant was not sent.')
946
+ );
947
+ err.captcha = { image, audio };
948
+ err.raw = response;
949
+ throw err;
950
+ }
951
+
952
+ await sendFunnelLog(store, waVersion, screen, 'challenge_submitted', 'captcha_submitted');
953
+ response = await sendRequest('/challenge', store, waVersion, true,
954
+ ['code', normalizeCodeResult(answer)]);
955
+
956
+ if (isSuccessful(response.status)) {
957
+ await sendFunnelLog(store, waVersion, 'account_verification_complete',
958
+ 'challenge_submitted', 'captcha_success');
959
+ return adoptCanonicalNumber(store, response);
960
+ }
961
+ if (hasChallenge(response)) {
962
+ await sendFunnelLog(store, waVersion, screen, 'challenge_retry', 'captcha_retry');
963
+ continue; // wrong answer, another one
964
+ }
965
+ if (is2FARequired(response)) return handle2FA(store, waVersion, opts);
966
+
967
+ const err = new Error('Registration CAPTCHA refused: ' +
968
+ (response.reason || response.status || JSON.stringify(response)));
969
+ err.raw = response;
970
+ throw err;
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Supply the two-step verification PIN the account has set.
976
+ *
977
+ * This is the account owner's own PIN, not anything the library can work out —
978
+ * without a handler to ask, the registration stops here.
979
+ */
980
+ async function handle2FA(store, waVersion, opts) {
981
+ await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_shown', 'twofac_prompt_shown');
982
+
983
+ let pin = null;
984
+ if (typeof opts.twoFactorPin === 'function') pin = await opts.twoFactorPin();
985
+ if (!pin) {
986
+ await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_abandoned', 'twofac_abandoned');
987
+ throw new Error(
988
+ 'This number has two-step verification switched on and the registration ' +
989
+ 'needs its PIN' +
990
+ (typeof opts.twoFactorPin === 'function'
991
+ ? ', but the twoFactorPin handler returned nothing.'
992
+ : '.\n Pass one to verifyCode: verifyCode(store, code, { twoFactorPin: async () => "123456" }).\n' +
993
+ ' It is the six-digit PIN set on the phone under Settings → Account → Two-step verification.')
994
+ );
995
+ }
996
+
997
+ await sendFunnelLog(store, waVersion, 'verify_twofac', 'twofac_submitted', 'twofac_submitted');
998
+ const response = await sendRequest('/security', store, waVersion, true,
999
+ ['code', normalizeCodeResult(pin)]);
1000
+
1001
+ if (isSuccessful(response.status)) {
1002
+ await sendFunnelLog(store, waVersion, 'account_verification_complete',
1003
+ 'twofac_submitted', 'twofac_success');
1004
+ return adoptCanonicalNumber(store, response);
1005
+ }
1006
+
1007
+ const err = new Error('Two-step verification PIN refused: ' +
1008
+ (response.reason || response.status || JSON.stringify(response)));
1009
+ err.raw = response;
1010
+ throw err;
1011
+ }
1012
+
670
1013
  // ---------- Public API ----------
671
1014
 
672
1015
  async function checkIfRegistered(store) {
@@ -778,18 +1121,30 @@ async function checkNumberStatus(phoneNumber) {
778
1121
  // Returns true → keys are fresh (safe to proceed with /code).
779
1122
  // Returns false → keys already registered (generate new store before /code).
780
1123
  async function assertRegistrationKeys(store, waVersion) {
1124
+ // The session-start event the native client fires before it knows the number.
1125
+ await sendPrePnFunnelLog(store, waVersion, 'session_start', 'registration_session_start');
1126
+ await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_attempt');
1127
+
781
1128
  for (let attempt = 0; attempt < 2; attempt++) {
782
1129
  try {
783
1130
  const result = await sendRequest('/exist', store, waVersion, false, null);
784
1131
  // reason === 'incorrect' → keys not found → fresh
785
- if (result && result.reason === 'incorrect') return true;
1132
+ if (result && result.reason === 'incorrect') {
1133
+ await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_success');
1134
+ return true;
1135
+ }
786
1136
  // Any non-ok status on 2nd attempt → still treat as fresh (network/server glitch)
787
- if (attempt === 1 && result && result.status && result.status !== 'ok') return true;
1137
+ if (attempt === 1 && result && result.status && result.status !== 'ok') {
1138
+ await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_success');
1139
+ return true;
1140
+ }
788
1141
  } catch (_) {
789
1142
  // Network error = treat keys as fresh
790
1143
  return true;
791
1144
  }
792
1145
  }
1146
+
1147
+ await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_failure');
793
1148
  return false;
794
1149
  }
795
1150
 
@@ -843,10 +1198,20 @@ async function requestSmsCode(store, method, opts) {
843
1198
  while (true) {
844
1199
  // Rebuilt per attempt so client_metrics carries the current attempt count.
845
1200
  const extra = getRequestVerificationCodeParameters(store, m, _regMeta, _device, attemptNum);
1201
+
1202
+ // The screen a later event belongs to is named after the method asked
1203
+ // for here, so record it before the request rather than after.
1204
+ store._lastRequestedMethod = m;
1205
+ const verifyScreen = currentVerifyScreen(store);
1206
+ await sendFunnelLog(store, waVersion, verifyScreen, 'request_code', 'request_code_attempt');
1207
+
846
1208
  const result = await sendRequest('/code', store, waVersion, true, extra);
847
1209
 
848
1210
  const status = result.status;
849
- if (status === 'ok' || status === 'sent') return result;
1211
+ if (status === 'ok' || status === 'sent') {
1212
+ await sendFunnelLog(store, waVersion, verifyScreen, 'request_code', 'request_code_success');
1213
+ return result;
1214
+ }
850
1215
 
851
1216
  const reason = result.reason || status || '';
852
1217
 
@@ -903,16 +1268,23 @@ async function requestSmsCode(store, method, opts) {
903
1268
  return result;
904
1269
  }
905
1270
 
906
- async function verifyCode(store, code) {
1271
+ async function verifyCode(store, code, opts) {
1272
+ opts = opts || {};
907
1273
  const _device = getDeviceConfig();
908
1274
  const waVersion = await fetchWaVersion(_device);
909
1275
  store.version = waVersion;
910
1276
  store.device = _device;
911
1277
  const normalized = code.replace(/[\s\-]/g, '').replace(/\D/g, '');
1278
+
1279
+ const screen = currentVerifyScreen(store);
1280
+ await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
1281
+
912
1282
  const result = await sendRequest('/register', store, waVersion, true, ['code', normalized]);
913
1283
 
914
1284
  const status = result.status;
915
1285
  if (status === 'ok' || status === 'sent' || status === 'verified') {
1286
+ await sendFunnelLog(store, waVersion, 'account_verification_complete',
1287
+ 'submit_code', 'submit_code_success');
916
1288
  // Adopt the number in the server's own form.
917
1289
  //
918
1290
  // WhatsApp answers with `login`, the canonical digits it filed the account
@@ -921,15 +1293,16 @@ async function verifyCode(store, code) {
921
1293
  // under the eight-digit form, and a session saved under the typed number
922
1294
  // sends a username on every connection that matches no registration. The
923
1295
  // handshake then fails with 401 and nothing in the message hints at why.
924
- const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
925
- if (canonical && canonical !== String(store.phoneNumber)) {
926
- result.canonicalPhoneNumber = canonical;
927
- result.typedPhoneNumber = String(store.phoneNumber);
928
- store.phoneNumber = canonical;
929
- }
930
- return result;
1296
+ return adoptCanonicalNumber(store, result);
931
1297
  }
932
1298
 
1299
+ // Before reading the reason, because neither of these arrives as one. A
1300
+ // captcha comes as image and audio blobs with nothing else to go on, and a
1301
+ // PIN demand names a reason that none of the branches below would match —
1302
+ // both would otherwise fall through to the generic failure.
1303
+ if (hasChallenge(result)) return handleChallenge(store, waVersion, result, opts);
1304
+ if (is2FARequired(result)) return handle2FA(store, waVersion, opts);
1305
+
933
1306
  const reason = result.reason || '';
934
1307
  if (reason === 'missing') {
935
1308
  throw new Error(
@@ -943,13 +1316,56 @@ async function verifyCode(store, code) {
943
1316
  if (/too_many/.test(reason)) {
944
1317
  throw new Error('Verification failed: too many wrong attempts. Wait a few minutes then request a new code.');
945
1318
  }
946
- // Apple App Store age-verification / parental-consent gate.
947
- // The code was accepted, but WhatsApp requires the Apple account holder to
948
- // approve the app download before the registration can complete.
1319
+ // Age / consent gate.
1320
+ //
1321
+ // The server found the account and did not complain about the code — it is
1322
+ // asking for an age signal that only a real app-store install carries, and
1323
+ // refusing to finish without it. Brazil is where this shows up, which fits:
1324
+ // it is the market with an age-verification law behind it.
1325
+ //
1326
+ // Worth knowing before trying anything: the iOS request carries six fields
1327
+ // and not one of them says anything about consent, terms or age. The Android
1328
+ // one carries tos_version, education_screen_displayed and
1329
+ // clicked_education_link. That is the difference to try first, and it costs
1330
+ // nothing to try.
949
1331
  if (result.pending === 'app_store_age' || reason === 'consent') {
950
- return result;
1332
+ const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
1333
+ const lines = [
1334
+ 'Verification failed: WhatsApp wants an age-consent signal for this number ' +
1335
+ 'and will not finish the registration without one' +
1336
+ (result.pending ? ' (pending: ' + result.pending + ')' : '') + '.',
1337
+ ' The code itself was not refused — the account was found' +
1338
+ (canonical ? ', filed as +' + canonical : '') + '.'
1339
+ ];
1340
+ if (canonical && canonical !== String(store.phoneNumber)) {
1341
+ lines.push(' Note the digits: you typed +' + store.phoneNumber +
1342
+ ', WhatsApp keeps it as +' + canonical + '. Brazilian mobiles gained a ' +
1343
+ 'ninth digit that WhatsApp did not adopt, and that alone is not the failure here.');
1344
+ }
1345
+ lines.push(
1346
+ ' Try registering as an Android device instead — that request carries the ' +
1347
+ 'terms and education fields the iOS one has none of:',
1348
+ ' WA_OS=android wa registration --code ' + store.phoneNumber,
1349
+ ' If that is refused too, this number needs the real app once, on a phone, ' +
1350
+ 'to clear the gate.'
1351
+ );
1352
+ const err = new Error(lines.join('\n'));
1353
+ err.reason = reason;
1354
+ err.pending = result.pending || null;
1355
+ err.canonicalPhoneNumber = canonical;
1356
+ err.raw = result;
1357
+ throw err;
951
1358
  }
952
1359
  throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
953
1360
  }
954
1361
 
955
1362
  module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
1363
+
1364
+ // The HTTP reader, exposed for tests. Not part of the public API.
1365
+ module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
1366
+
1367
+ // Challenge / two-factor internals, exposed for tests. Not part of the public API.
1368
+ module.exports._verify = {
1369
+ hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
1370
+ normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled
1371
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.11.0",
3
+ "version": "5.12.0",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",