whalibmob 5.11.1 → 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
  //
@@ -744,6 +745,18 @@ function httpPost(path, body, waVersion, authHeader) {
744
745
 
745
746
  async function sendRequest(path, store, waVersion, useToken, extraPairs) {
746
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) {
747
760
  const enc = encryptPayload(plaintext); // base64url ENC payload (no prefix)
748
761
  let body = 'ENC=' + enc;
749
762
 
@@ -760,6 +773,243 @@ async function sendRequest(path, store, waVersion, useToken, extraPairs) {
760
773
  return httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
761
774
  }
762
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
+
763
1013
  // ---------- Public API ----------
764
1014
 
765
1015
  async function checkIfRegistered(store) {
@@ -871,18 +1121,30 @@ async function checkNumberStatus(phoneNumber) {
871
1121
  // Returns true → keys are fresh (safe to proceed with /code).
872
1122
  // Returns false → keys already registered (generate new store before /code).
873
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
+
874
1128
  for (let attempt = 0; attempt < 2; attempt++) {
875
1129
  try {
876
1130
  const result = await sendRequest('/exist', store, waVersion, false, null);
877
1131
  // reason === 'incorrect' → keys not found → fresh
878
- 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
+ }
879
1136
  // Any non-ok status on 2nd attempt → still treat as fresh (network/server glitch)
880
- 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
+ }
881
1141
  } catch (_) {
882
1142
  // Network error = treat keys as fresh
883
1143
  return true;
884
1144
  }
885
1145
  }
1146
+
1147
+ await sendFunnelLog(store, waVersion, 'enter_number', 'exist_check', 'exist_failure');
886
1148
  return false;
887
1149
  }
888
1150
 
@@ -936,10 +1198,20 @@ async function requestSmsCode(store, method, opts) {
936
1198
  while (true) {
937
1199
  // Rebuilt per attempt so client_metrics carries the current attempt count.
938
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
+
939
1208
  const result = await sendRequest('/code', store, waVersion, true, extra);
940
1209
 
941
1210
  const status = result.status;
942
- 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
+ }
943
1215
 
944
1216
  const reason = result.reason || status || '';
945
1217
 
@@ -996,16 +1268,23 @@ async function requestSmsCode(store, method, opts) {
996
1268
  return result;
997
1269
  }
998
1270
 
999
- async function verifyCode(store, code) {
1271
+ async function verifyCode(store, code, opts) {
1272
+ opts = opts || {};
1000
1273
  const _device = getDeviceConfig();
1001
1274
  const waVersion = await fetchWaVersion(_device);
1002
1275
  store.version = waVersion;
1003
1276
  store.device = _device;
1004
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
+
1005
1282
  const result = await sendRequest('/register', store, waVersion, true, ['code', normalized]);
1006
1283
 
1007
1284
  const status = result.status;
1008
1285
  if (status === 'ok' || status === 'sent' || status === 'verified') {
1286
+ await sendFunnelLog(store, waVersion, 'account_verification_complete',
1287
+ 'submit_code', 'submit_code_success');
1009
1288
  // Adopt the number in the server's own form.
1010
1289
  //
1011
1290
  // WhatsApp answers with `login`, the canonical digits it filed the account
@@ -1014,15 +1293,16 @@ async function verifyCode(store, code) {
1014
1293
  // under the eight-digit form, and a session saved under the typed number
1015
1294
  // sends a username on every connection that matches no registration. The
1016
1295
  // handshake then fails with 401 and nothing in the message hints at why.
1017
- const canonical = result.login ? String(result.login).replace(/\D/g, '') : null;
1018
- if (canonical && canonical !== String(store.phoneNumber)) {
1019
- result.canonicalPhoneNumber = canonical;
1020
- result.typedPhoneNumber = String(store.phoneNumber);
1021
- store.phoneNumber = canonical;
1022
- }
1023
- return result;
1296
+ return adoptCanonicalNumber(store, result);
1024
1297
  }
1025
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
+
1026
1306
  const reason = result.reason || '';
1027
1307
  if (reason === 'missing') {
1028
1308
  throw new Error(
@@ -1036,11 +1316,45 @@ async function verifyCode(store, code) {
1036
1316
  if (/too_many/.test(reason)) {
1037
1317
  throw new Error('Verification failed: too many wrong attempts. Wait a few minutes then request a new code.');
1038
1318
  }
1039
- // Apple App Store age-verification / parental-consent gate.
1040
- // The code was accepted, but WhatsApp requires the Apple account holder to
1041
- // 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.
1042
1331
  if (result.pending === 'app_store_age' || reason === 'consent') {
1043
- 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;
1044
1358
  }
1045
1359
  throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
1046
1360
  }
@@ -1049,3 +1363,9 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
1049
1363
 
1050
1364
  // The HTTP reader, exposed for tests. Not part of the public API.
1051
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.1",
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",