whalibmob 5.6.4 → 5.6.9
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 +16 -4
- package/README.md +35 -0
- package/cli.js +67 -7
- package/lib/Client.js +151 -24
- package/lib/Registration.js +113 -18
- package/lib/noise.js +37 -2
- package/package.json +3 -2
package/.env.example
CHANGED
|
@@ -61,11 +61,23 @@
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
# ─── Proxy ───────────────────────────────────────────────────────────────────
|
|
64
|
-
# Route registration traffic through a
|
|
65
|
-
#
|
|
66
|
-
#
|
|
64
|
+
# Route registration traffic through a SOCKS proxy. Requires the `socks`
|
|
65
|
+
# package: npm install socks
|
|
66
|
+
#
|
|
67
|
+
# A bare host:port is assumed to be socks5. Add a scheme to be explicit, and
|
|
68
|
+
# credentials if the proxy needs them. Percent-encode any @ or : in a password.
|
|
69
|
+
#
|
|
70
|
+
# TOR_PROXY takes precedence over SOCKS_PROXY when both are set.
|
|
71
|
+
# The default Tor SOCKS port is 9050; the SOCKS default is 1080.
|
|
72
|
+
#
|
|
67
73
|
# TOR_PROXY=127.0.0.1:9050
|
|
68
|
-
#
|
|
74
|
+
# TOR_PROXY=socks5://127.0.0.1:9050
|
|
75
|
+
# SOCKS_PROXY=socks5://user:pass@proxy.example.com:1080
|
|
76
|
+
# SOCKS_PROXY=socks5h://proxy.example.com:1080
|
|
77
|
+
# SOCKS_PROXY=socks4://proxy.example.com:1080
|
|
78
|
+
#
|
|
79
|
+
# Only if `socks` lives somewhere require() cannot find it:
|
|
80
|
+
# WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
|
|
69
81
|
|
|
70
82
|
|
|
71
83
|
# ─── Frida attestation bridge ────────────────────────────────────────────────
|
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ npm install -g whalibmob
|
|
|
150
150
|
- [Handling Reconnects](#handling-reconnects)
|
|
151
151
|
- [Knowing Which Mode You Are In](#knowing-which-mode-you-are-in)
|
|
152
152
|
- [Two Sessions on One Number](#two-sessions-on-one-number)
|
|
153
|
+
- [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
|
|
153
154
|
- [Saving & Restoring Sessions](#saving--restoring-sessions)
|
|
154
155
|
- [Signal Store Utilities](#signal-store-utilities)
|
|
155
156
|
- [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
|
|
@@ -806,6 +807,40 @@ await client.connectWeb(PHONE) // web / companion, over WebSocket
|
|
|
806
807
|
|
|
807
808
|
Use two `WhalibmobClient` instances if you want both at once.
|
|
808
809
|
|
|
810
|
+
## Routing Registration Through a Proxy
|
|
811
|
+
|
|
812
|
+
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.
|
|
813
|
+
|
|
814
|
+
Install the optional dependency and set one environment variable:
|
|
815
|
+
|
|
816
|
+
```sh
|
|
817
|
+
npm install socks
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
```sh
|
|
821
|
+
# Tor
|
|
822
|
+
export TOR_PROXY=socks5://127.0.0.1:9050
|
|
823
|
+
|
|
824
|
+
# a residential proxy that needs credentials
|
|
825
|
+
export SOCKS_PROXY=socks5://user:pass@proxy.example.com:1080
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
A bare `host:port` is assumed to be SOCKS5, so `TOR_PROXY=127.0.0.1:9050` works too. `socks5`, `socks5h`, `socks4` and `socks4a` are all accepted, and `TOR_PROXY` wins if both variables are set.
|
|
829
|
+
|
|
830
|
+
If the password contains an `@` or a `:`, percent-encode it — `p@ss:word` becomes `p%40ss%3Aword`. whalibmob decodes it before handing it to the proxy.
|
|
831
|
+
|
|
832
|
+
```js
|
|
833
|
+
// or from code, before you call any registration function
|
|
834
|
+
process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'
|
|
835
|
+
|
|
836
|
+
await requestSmsCode(phone, store)
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
> [!NOTE]
|
|
840
|
+
> This covers the registration traffic to `v.whatsapp.net`. The message socket and media transfers are not proxied — if you need those behind a proxy as well, open an issue describing the setup.
|
|
841
|
+
|
|
842
|
+
If `socks` is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. In the rare case the package lives somewhere `require()` cannot find it, `WA_SOCKS_LIB` takes an absolute path to it.
|
|
843
|
+
|
|
809
844
|
## Saving & Restoring Sessions
|
|
810
845
|
|
|
811
846
|
Sessions are automatically persisted to disk as JSON files under the `sessionDir` you provide. The file is named `<phone>.json`. On the next `client.init()` call the session is restored and no re-registration is needed.
|
package/cli.js
CHANGED
|
@@ -576,6 +576,24 @@ function attachEvents(client) {
|
|
|
576
576
|
? 'link again with /pair'
|
|
577
577
|
: 're-register with /reg code';
|
|
578
578
|
fail('session revoked: ' + f.reason + ' — ' + how);
|
|
579
|
+
_rl && _rl.resume();
|
|
580
|
+
notConnected();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Someone else opened a session for this number. Reconnecting would fight
|
|
584
|
+
// them for it, so the client stands down and says so.
|
|
585
|
+
client.on('connection_replaced', (r) => {
|
|
586
|
+
_rl && _rl.pause();
|
|
587
|
+
fail('connection replaced');
|
|
588
|
+
out(' ' + r.message);
|
|
589
|
+
_rl && _rl.resume();
|
|
590
|
+
notConnected();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// A stanza whose shape we mishandled. Not fatal — the connection kept going.
|
|
594
|
+
client.on('node_error', (e) => {
|
|
595
|
+
_rl && _rl.pause();
|
|
596
|
+
fail('could not handle a <' + e.tag + '>: ' + (e.err && e.err.message));
|
|
579
597
|
_rl && (_rl.resume(), _rl.prompt(true));
|
|
580
598
|
});
|
|
581
599
|
|
|
@@ -585,7 +603,8 @@ function attachEvents(client) {
|
|
|
585
603
|
fail('rejected by WhatsApp (' + r.reason +
|
|
586
604
|
(r.location ? ', edge ' + r.location : '') + ')');
|
|
587
605
|
out(' ' + r.message);
|
|
588
|
-
_rl &&
|
|
606
|
+
_rl && _rl.resume();
|
|
607
|
+
notConnected();
|
|
589
608
|
});
|
|
590
609
|
|
|
591
610
|
client.on('error', (e) => {
|
|
@@ -715,7 +734,7 @@ async function doConnectWeb(phone, opts) {
|
|
|
715
734
|
client.once('auth_failure', () => clearInterval(keepAlive));
|
|
716
735
|
} catch (e) {
|
|
717
736
|
fail(e.message);
|
|
718
|
-
|
|
737
|
+
notConnected();
|
|
719
738
|
}
|
|
720
739
|
}
|
|
721
740
|
|
|
@@ -735,7 +754,7 @@ async function doConnect(phone) {
|
|
|
735
754
|
client.once('auth_failure', () => {
|
|
736
755
|
fail('auth failed — session revoked');
|
|
737
756
|
out('use /reg code ' + phone + ' to re-register');
|
|
738
|
-
|
|
757
|
+
notConnected();
|
|
739
758
|
});
|
|
740
759
|
|
|
741
760
|
try {
|
|
@@ -745,11 +764,48 @@ async function doConnect(phone) {
|
|
|
745
764
|
client.once('auth_failure', () => clearInterval(keepAlive));
|
|
746
765
|
} catch (e) {
|
|
747
766
|
fail(e.message);
|
|
748
|
-
|
|
749
|
-
|
|
767
|
+
if (/No (primary )?session for/i.test(e.message)) {
|
|
768
|
+
if (hasWebSession(phone)) {
|
|
769
|
+
out(' this number is linked as a companion — use: /pair ' + phone);
|
|
770
|
+
} else {
|
|
771
|
+
out(' register it: /reg code ' + phone + ' then /reg confirm ' + phone + ' <code>');
|
|
772
|
+
out(' or link it to an account already on a phone: /pair ' + phone);
|
|
773
|
+
}
|
|
774
|
+
} else if (e.code === '401' || /auth failure 401/i.test(e.message)) {
|
|
775
|
+
// 401 says the credentials were refused; it does not say whether the
|
|
776
|
+
// number itself is still registered. Ask, so the next step is obvious.
|
|
777
|
+
out(' checking whether the number is still registered...');
|
|
778
|
+
try {
|
|
779
|
+
const probe = await client.checkSessionAlive();
|
|
780
|
+
if (probe.alive) {
|
|
781
|
+
out(' the number IS still registered, but this device was logged out.');
|
|
782
|
+
out(' register it again: /reg code ' + phone);
|
|
783
|
+
} else if (probe.error) {
|
|
784
|
+
out(' could not reach the registration endpoint: ' + probe.error);
|
|
785
|
+
} else {
|
|
786
|
+
out(' the registration is gone server-side (' + (probe.status || 'no status') + ').');
|
|
787
|
+
out(' register it again: /reg code ' + phone);
|
|
788
|
+
}
|
|
789
|
+
} catch (probeErr) {
|
|
790
|
+
out(' status check failed: ' + probeErr.message);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
notConnected();
|
|
750
794
|
}
|
|
751
795
|
}
|
|
752
796
|
|
|
797
|
+
// Put the shell back in its disconnected state.
|
|
798
|
+
//
|
|
799
|
+
// The prompt is the only standing indication of whether there is a session, so
|
|
800
|
+
// it has to be right after a failure: a prompt reading "wa +<number>>" under an
|
|
801
|
+
// error saying there is no session for that number is the shell contradicting
|
|
802
|
+
// itself.
|
|
803
|
+
function notConnected() {
|
|
804
|
+
_client = null;
|
|
805
|
+
_phone = null;
|
|
806
|
+
if (_rl) { _rl.setPrompt('wa> '); _rl.prompt(); }
|
|
807
|
+
}
|
|
808
|
+
|
|
753
809
|
// ─── guard ────────────────────────────────────────────────────────────────────
|
|
754
810
|
|
|
755
811
|
function requireConn() {
|
|
@@ -2039,7 +2095,11 @@ async function main() {
|
|
|
2039
2095
|
const method = flags.pair || forced === 'pair' || forced === 'pairing' ? 'pairing'
|
|
2040
2096
|
: flags.sms || forced === 'sms' ? 'sms'
|
|
2041
2097
|
: await askLoginMethod(phone);
|
|
2042
|
-
|
|
2098
|
+
// Plain `wa>` until the connection actually opens. Naming the number in the
|
|
2099
|
+
// prompt before that says "connected as this number" while the line above
|
|
2100
|
+
// it says there is no session, which is the opposite of what happened —
|
|
2101
|
+
// doConnect sets the real prompt from the 'connected' event.
|
|
2102
|
+
openShell();
|
|
2043
2103
|
out('connecting to +' + phone + '...');
|
|
2044
2104
|
if (method === 'pairing') await doConnectWeb(phone);
|
|
2045
2105
|
else await doConnect(phone);
|
|
@@ -2053,7 +2113,7 @@ async function main() {
|
|
|
2053
2113
|
if (!phone) { fail('phone number required'); process.exit(1); }
|
|
2054
2114
|
const custom = (sub ? pos[0] : pos[1]) ||
|
|
2055
2115
|
(typeof flags.code === 'string' ? flags.code : undefined);
|
|
2056
|
-
openShell(
|
|
2116
|
+
openShell();
|
|
2057
2117
|
out('linking +' + phone + ' to an existing WhatsApp account...');
|
|
2058
2118
|
await doConnectWeb(phone, { customCode: custom });
|
|
2059
2119
|
return;
|
package/lib/Client.js
CHANGED
|
@@ -4,6 +4,7 @@ const { dbg: _whaDbg, configureLogger: _whaConfigLogger } = require('./logger');
|
|
|
4
4
|
|
|
5
5
|
const EventEmitter = require('events');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
7
8
|
const crypto = require('crypto');
|
|
8
9
|
const { NoiseSocket } = require('./noise');
|
|
9
10
|
const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
|
|
@@ -278,7 +279,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
278
279
|
const sessionFile = path.join(this._sessionDir, `${phoneNumber}.json`);
|
|
279
280
|
this._store = loadStore(sessionFile);
|
|
280
281
|
if (!this._store) {
|
|
281
|
-
|
|
282
|
+
// Say what is missing, and say it accurately. The old text pointed at
|
|
283
|
+
// 'wa registration -R', a flag this CLI has never accepted, so anyone who
|
|
284
|
+
// followed it got a second error instead of a registered number. A
|
|
285
|
+
// library has no business teaching CLI syntax either — the caller knows
|
|
286
|
+
// which interface it is; this only has to name the state.
|
|
287
|
+
const hasWeb = fs.existsSync(path.join(this._sessionDir, `${phoneNumber}.web.json`));
|
|
288
|
+
throw new Error(
|
|
289
|
+
hasWeb
|
|
290
|
+
? `No primary session for ${phoneNumber}, but it is linked as a ` +
|
|
291
|
+
'companion device — use connectWeb() rather than init().'
|
|
292
|
+
: `No session for ${phoneNumber} — register the number first, or ` +
|
|
293
|
+
'link it to an existing account with connectWeb().'
|
|
294
|
+
);
|
|
282
295
|
}
|
|
283
296
|
|
|
284
297
|
const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
|
|
@@ -766,6 +779,28 @@ class WhalibmobClient extends EventEmitter {
|
|
|
766
779
|
this._closing = false;
|
|
767
780
|
}
|
|
768
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Ask the server whether this session's registration is still alive.
|
|
784
|
+
*
|
|
785
|
+
* The read-only /exist probe, run with the credentials already on disk. It
|
|
786
|
+
* answers the question a 401 leaves open: whether the number is still
|
|
787
|
+
* registered and only this device was cut loose, or whether the registration
|
|
788
|
+
* is gone entirely. Sends nothing to the phone and requests no code, so it
|
|
789
|
+
* is safe to run on a failure.
|
|
790
|
+
*
|
|
791
|
+
* @returns {Promise<{alive: boolean, status: string|null, raw: object|null, error?: string}>}
|
|
792
|
+
*/
|
|
793
|
+
async checkSessionAlive() {
|
|
794
|
+
if (!this._store) throw new Error('No session loaded');
|
|
795
|
+
try {
|
|
796
|
+
const res = await checkIfRegistered(this._store);
|
|
797
|
+
const status = (res && (res.status || res.reason)) || null;
|
|
798
|
+
return { alive: status === 'ok', status, raw: res };
|
|
799
|
+
} catch (err) {
|
|
800
|
+
return { alive: false, status: null, raw: null, error: err.message };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
769
804
|
// ─── Reconnection ─────────────────────────────────────────────────────────
|
|
770
805
|
|
|
771
806
|
_scheduleReconnect() {
|
|
@@ -984,6 +1019,29 @@ class WhalibmobClient extends EventEmitter {
|
|
|
984
1019
|
// We do NOT decrypt the patches — that requires the full LTHASH machinery.
|
|
985
1020
|
// We DO update our version counters from the server response so that we
|
|
986
1021
|
// never re-request the same snapshot twice.
|
|
1022
|
+
/**
|
|
1023
|
+
* Acknowledge a dirty bit so the server stops announcing it.
|
|
1024
|
+
*
|
|
1025
|
+
* `<ib><dirty type="..."/></ib>` is the server saying something changed while
|
|
1026
|
+
* we were away. Whatever the client does about it, the bit has to be cleared
|
|
1027
|
+
* or the same announcement arrives on every connection.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {string} type the dirty type, e.g. 'groups'
|
|
1030
|
+
* @param {number|string} [fromTs] the timestamp the announcement carried
|
|
1031
|
+
*/
|
|
1032
|
+
_cleanDirtyBits(type, fromTs) {
|
|
1033
|
+
if (!this._socket || !this._connected) return;
|
|
1034
|
+
const attrs = { type: String(type) };
|
|
1035
|
+
if (fromTs) attrs.timestamp = String(fromTs);
|
|
1036
|
+
_whaDbg('[DBG] CLEAN_DIRTY type=' + type + (fromTs ? ' t=' + fromTs : ''));
|
|
1037
|
+
this._socket.sendNode(new BinaryNode('iq', {
|
|
1038
|
+
id: this._genMsgId(),
|
|
1039
|
+
to: 's.whatsapp.net',
|
|
1040
|
+
type: 'set',
|
|
1041
|
+
xmlns: 'urn:xmpp:whatsapp:dirty'
|
|
1042
|
+
}, [new BinaryNode('clean', attrs, null)]));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
987
1045
|
_sendAppStateSyncForTypes(dirtyTypes) {
|
|
988
1046
|
if (!this._socket || !this._connected) return;
|
|
989
1047
|
|
|
@@ -1002,7 +1060,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1002
1060
|
for (const type of dirtyTypes) {
|
|
1003
1061
|
const mapped = COLLECTION_MAP[type];
|
|
1004
1062
|
if (!mapped) {
|
|
1005
|
-
|
|
1063
|
+
// "groups" is not an app-state collection, but it is still a dirty bit
|
|
1064
|
+
// the server expects to be cleared. Ignoring it meant the server
|
|
1065
|
+
// re-announced it on every single connection, forever.
|
|
1066
|
+
_whaDbg('[DBG] DIRTY type=' + type + ' — not an app-state collection, clearing the bit');
|
|
1067
|
+
this._cleanDirtyBits(type);
|
|
1006
1068
|
continue;
|
|
1007
1069
|
}
|
|
1008
1070
|
for (const c of mapped) collections.add(c);
|
|
@@ -1099,18 +1161,31 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1099
1161
|
try { _whaDbg('[DBG] IQ_ERROR content=' + JSON.stringify(node.content)); } catch(_) {}
|
|
1100
1162
|
}
|
|
1101
1163
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1164
|
+
// One malformed stanza must not take the connection down with it.
|
|
1165
|
+
//
|
|
1166
|
+
// A throw here propagates out through the socket's frame reader, which
|
|
1167
|
+
// reports it as a socket error — so a single unhandled shape in one
|
|
1168
|
+
// notification looked like the transport failing, and every stanza queued
|
|
1169
|
+
// behind it in the same offline batch was lost. Contain it: log the stanza
|
|
1170
|
+
// that did it, tell the caller, carry on reading.
|
|
1171
|
+
try {
|
|
1172
|
+
if (tag === 'iq') this._handleIq(node);
|
|
1173
|
+
else if (tag === 'message') this._handleMessage(node);
|
|
1174
|
+
else if (tag === 'receipt') this._handleReceipt(node);
|
|
1175
|
+
else if (tag === 'ack') this._handleAck(node);
|
|
1176
|
+
else if (tag === 'notification') this._handleNotification(node);
|
|
1177
|
+
else if (tag === 'presence') this._handlePresence(node);
|
|
1178
|
+
else if (tag === 'call') this._handleCall(node);
|
|
1179
|
+
else if (tag === 'ib') this._handleIb(node);
|
|
1180
|
+
else if (tag === 'success') this.emit('session_refresh', { node }); // late success (re-auth)
|
|
1181
|
+
else if (tag === 'failure') this._handleFailure(node);
|
|
1182
|
+
else if (tag === 'stream:error') this._handleStreamError(node);
|
|
1183
|
+
else this.emit('node', node);
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
_whaDbg('[DBG] NODE_HANDLER_ERR tag=' + tag + ' err=' + (err && err.message) +
|
|
1186
|
+
' attrs=' + JSON.stringify(node.attrs || {}));
|
|
1187
|
+
this.emit('node_error', { tag, node, err });
|
|
1188
|
+
}
|
|
1114
1189
|
}
|
|
1115
1190
|
|
|
1116
1191
|
// A stream error ends the connection; the reconnect in _onClose picks it up.
|
|
@@ -1122,16 +1197,55 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1122
1197
|
_handleStreamError(node) {
|
|
1123
1198
|
const attrs = (node && node.attrs) || {};
|
|
1124
1199
|
const code = attrs.code ? String(attrs.code) : null;
|
|
1125
|
-
const reason = attrs.reason || code || 'unknown';
|
|
1126
1200
|
|
|
1127
1201
|
if (code === '515') {
|
|
1128
1202
|
_whaDbg('[DBG] STREAM_ERROR 515 — restart required after pairing, reconnecting');
|
|
1129
|
-
this.emit('restart_required', { reason });
|
|
1203
|
+
this.emit('restart_required', { reason: '515' });
|
|
1130
1204
|
return;
|
|
1131
1205
|
}
|
|
1132
1206
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1207
|
+
// Most stream errors carry no code at all — the reason is a child element,
|
|
1208
|
+
// and for <conflict> the type attribute on it says which kind. Reading only
|
|
1209
|
+
// the attributes reported every one of these as "code=null reason=unknown",
|
|
1210
|
+
// which is the same message for a session someone else took over and a
|
|
1211
|
+
// session that was deleted.
|
|
1212
|
+
const child = Array.isArray(node.content) && node.content[0] ? node.content[0] : null;
|
|
1213
|
+
const childTag = child && child.description ? child.description : null;
|
|
1214
|
+
const conflictType = childTag === 'conflict' && child.attrs
|
|
1215
|
+
? String(child.attrs.type || '') : null;
|
|
1216
|
+
|
|
1217
|
+
if (conflictType === 'replaced') {
|
|
1218
|
+
// Another client opened a session for this account and the server handed
|
|
1219
|
+
// it over. Reconnecting immediately just takes it back, and the two ends
|
|
1220
|
+
// trade the session until one of them is stopped — so this one stands
|
|
1221
|
+
// down and says why.
|
|
1222
|
+
_whaDbg('[DBG] STREAM_ERROR conflict/replaced — another client took the session');
|
|
1223
|
+
this._fatal = true;
|
|
1224
|
+
this.emit('connection_replaced', {
|
|
1225
|
+
message: 'Another client connected with this number and took over the ' +
|
|
1226
|
+
'session. Reconnecting would take it back and the two would ' +
|
|
1227
|
+
'keep displacing each other, so this connection has stopped. ' +
|
|
1228
|
+
'Close the other client, then connect again.'
|
|
1229
|
+
});
|
|
1230
|
+
this.disconnect();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (conflictType === 'device_removed') {
|
|
1235
|
+
// The companion was unlinked from the phone. Same meaning as a 401.
|
|
1236
|
+
_whaDbg('[DBG] STREAM_ERROR conflict/device_removed — unlinked');
|
|
1237
|
+
this._fatal = true;
|
|
1238
|
+
this.emit('auth_failure', {
|
|
1239
|
+
reason: '401', location: null, loggedOut: true, node
|
|
1240
|
+
});
|
|
1241
|
+
this.disconnect();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const reason = attrs.reason || conflictType || childTag || code || 'unknown';
|
|
1246
|
+
_whaDbg('[DBG] STREAM_ERROR code=' + code + ' child=' + childTag +
|
|
1247
|
+
' conflict=' + conflictType + ' reason=' + reason);
|
|
1248
|
+
this.emit('stream_error', { reason, code, conflictType, childTag, node });
|
|
1135
1249
|
}
|
|
1136
1250
|
|
|
1137
1251
|
// ─── IQ handling ──────────────────────────────────────────────────────────
|
|
@@ -1921,7 +2035,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1921
2035
|
// Server push: a contact's linked device list changed (they linked or
|
|
1922
2036
|
// unlinked a tablet, desktop, etc.). Invalidate that phone's cache entry
|
|
1923
2037
|
// so the next send triggers a fresh usync IQ and reaches all their devices.
|
|
1924
|
-
|
|
2038
|
+
// BinaryNode decodes an AD-encoded JID into an object, not a string, so
|
|
2039
|
+
// this has to be stringified before it can be split. It was not, and a
|
|
2040
|
+
// <notification type="devices"> from a LID-addressed contact threw
|
|
2041
|
+
// "fromJid.split is not a function" out of the node handler — taking the
|
|
2042
|
+
// whole dispatch down with it, so every notification behind it in the
|
|
2043
|
+
// offline queue was dropped too.
|
|
2044
|
+
const fromJid = String(attrs.from || '');
|
|
1925
2045
|
const fromPhone = fromJid.split('@')[0].split(':')[0];
|
|
1926
2046
|
if (fromPhone && this._devMgr) {
|
|
1927
2047
|
this._devMgr._dcDel([fromPhone]);
|
|
@@ -1945,7 +2065,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1945
2065
|
// Server push: a contact re-registered WhatsApp (new identity key / new phone).
|
|
1946
2066
|
// Their old Signal sessions are no longer valid — clear them and the device
|
|
1947
2067
|
// cache so the next send builds a fresh pkmsg session from scratch.
|
|
1948
|
-
const fromJid = attrs.from || '';
|
|
2068
|
+
const fromJid = String(attrs.from || '');
|
|
1949
2069
|
const fromPhone = fromJid.split('@')[0].split(':')[0];
|
|
1950
2070
|
if (fromPhone && this._devMgr) {
|
|
1951
2071
|
this._devMgr._dcDel([fromPhone]);
|
|
@@ -2140,10 +2260,17 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2140
2260
|
return;
|
|
2141
2261
|
}
|
|
2142
2262
|
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
//
|
|
2146
|
-
|
|
2263
|
+
// Not every failure kills the session. whatsmeow's IsLoggedOut() marks the
|
|
2264
|
+
// ones that do — 401, 403, 405, 406, 409 — and treats the rest as worth
|
|
2265
|
+
// another attempt. A 500 or a 503 is the server having a bad minute; giving
|
|
2266
|
+
// up on the session for that would be throwing away a working registration.
|
|
2267
|
+
const LOGGED_OUT = ['401', '403', '405', '406', '409'];
|
|
2268
|
+
const spent = LOGGED_OUT.includes(reason);
|
|
2269
|
+
|
|
2270
|
+
_whaDbg('[DBG] FAILURE reason=' + reason + ' location=' + loc +
|
|
2271
|
+
' loggedOut=' + spent);
|
|
2272
|
+
this.emit('auth_failure', { reason, location: loc, loggedOut: spent, node });
|
|
2273
|
+
this._fatal = spent;
|
|
2147
2274
|
this._reconnecting = false;
|
|
2148
2275
|
this.disconnect();
|
|
2149
2276
|
}
|
package/lib/Registration.js
CHANGED
|
@@ -7,23 +7,109 @@ const curveJs = require('curve25519-js');
|
|
|
7
7
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
8
8
|
const attestation = require('./Attestation');
|
|
9
9
|
|
|
10
|
-
// ---------- SOCKS5 / Tor support ----------
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
10
|
+
// ---------- SOCKS4 / SOCKS5 / Tor support ----------
|
|
11
|
+
//
|
|
12
|
+
// Set SOCKS_PROXY (or TOR_PROXY) to route registration traffic through a proxy:
|
|
13
|
+
//
|
|
14
|
+
// socks5://127.0.0.1:9050 Tor
|
|
15
|
+
// socks5://user:pass@host:1080 authenticated residential proxy
|
|
16
|
+
// socks5h://host:1080 resolve the destination at the proxy
|
|
17
|
+
// socks4://host:1080 older proxies
|
|
18
|
+
//
|
|
19
|
+
// Requires the `socks` package: npm install socks
|
|
20
|
+
|
|
21
|
+
const SOCKS_SCHEMES = {
|
|
22
|
+
'socks:': 5,
|
|
23
|
+
'socks5:': 5,
|
|
24
|
+
'socks5h:': 5,
|
|
25
|
+
'socks4:': 4,
|
|
26
|
+
'socks4a:': 4
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Resolve the library the way every other dependency is resolved.
|
|
30
|
+
//
|
|
31
|
+
// This used to be a hardcoded absolute path into one particular machine's
|
|
32
|
+
// global npm directory. It resolved there and nowhere else, so proxy support
|
|
33
|
+
// was dead for everybody who installed whalibmob normally — the require threw
|
|
34
|
+
// MODULE_NOT_FOUND the moment a proxy was configured. WA_SOCKS_LIB stays as an
|
|
35
|
+
// escape hatch for unusual layouts, but it is an override now, not the only
|
|
36
|
+
// way in.
|
|
37
|
+
function loadSocks() {
|
|
38
|
+
const override = process.env.WA_SOCKS_LIB;
|
|
39
|
+
if (override) {
|
|
40
|
+
try { return require(override); }
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new Error('WA_SOCKS_LIB points at ' + override +
|
|
43
|
+
' but it could not be loaded: ' + err.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return require('socks');
|
|
48
|
+
} catch (_) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"A SOCKS proxy is configured but the 'socks' package is not installed. " +
|
|
51
|
+
'Run: npm install socks'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse a proxy URL into what SocksClient expects.
|
|
58
|
+
*
|
|
59
|
+
* Accepts the schemes above, with or without credentials, and defaults the
|
|
60
|
+
* port to 1080 the way every SOCKS client does.
|
|
61
|
+
*/
|
|
62
|
+
function parseSocksProxy(proxyUrl) {
|
|
63
|
+
let raw = String(proxyUrl || '').trim();
|
|
64
|
+
if (!raw) throw new Error('No SOCKS proxy configured');
|
|
65
|
+
|
|
66
|
+
// A bare host:port is what .env.example has always shown and what most people
|
|
67
|
+
// type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
|
|
68
|
+
// rest as the path, so it has to be filled in before parsing.
|
|
69
|
+
if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
|
|
70
|
+
|
|
71
|
+
let url;
|
|
72
|
+
try { url = new URL(raw); }
|
|
73
|
+
catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
|
|
74
|
+
|
|
75
|
+
const type = SOCKS_SCHEMES[url.protocol];
|
|
76
|
+
if (!type) {
|
|
77
|
+
throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
|
|
78
|
+
Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
|
|
79
|
+
}
|
|
80
|
+
if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
|
|
81
|
+
|
|
82
|
+
const proxy = {
|
|
83
|
+
host: url.hostname,
|
|
84
|
+
port: parseInt(url.port, 10) || 1080,
|
|
85
|
+
type
|
|
86
|
+
};
|
|
87
|
+
// Credentials may be percent-encoded in a URL; a password with an @ or a :
|
|
88
|
+
// in it is common enough on paid proxies to be worth decoding.
|
|
89
|
+
if (url.username) proxy.userId = decodeURIComponent(url.username);
|
|
90
|
+
if (url.password) proxy.password = decodeURIComponent(url.password);
|
|
91
|
+
return proxy;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// TOR_PROXY wins when both are set — the precedence .env.example has always
|
|
95
|
+
// documented.
|
|
96
|
+
function socksProxyUrl() {
|
|
97
|
+
return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
|
|
98
|
+
}
|
|
14
99
|
|
|
15
100
|
async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const dHost = 'v.whatsapp.net';
|
|
20
|
-
const dPort = 443;
|
|
101
|
+
const proxy = parseSocksProxy(proxyUrl);
|
|
102
|
+
const dHost = 'v.whatsapp.net';
|
|
103
|
+
const dPort = 443;
|
|
21
104
|
|
|
22
|
-
const { SocksClient } =
|
|
105
|
+
const { SocksClient } = loadSocks();
|
|
23
106
|
const { socket: rawSocket } = await SocksClient.createConnection({
|
|
24
|
-
proxy
|
|
107
|
+
proxy,
|
|
25
108
|
command: 'connect',
|
|
26
|
-
destination: { host: dHost, port: dPort }
|
|
109
|
+
destination: { host: dHost, port: dPort },
|
|
110
|
+
// Without this a proxy that accepts the TCP connection and then says
|
|
111
|
+
// nothing leaves the whole registration hanging with no error.
|
|
112
|
+
timeout: 20000
|
|
27
113
|
});
|
|
28
114
|
|
|
29
115
|
const tlsSocket = tls.connect({
|
|
@@ -50,16 +136,25 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
|
50
136
|
`Content-Length: ${Buffer.byteLength(body)}`
|
|
51
137
|
];
|
|
52
138
|
if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
|
|
53
|
-
|
|
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.
|
|
142
|
+
reqLines.push(`Connection: close`, '', body);
|
|
54
143
|
const req = reqLines.join('\r\n');
|
|
55
144
|
|
|
56
145
|
tlsSocket.write(req);
|
|
57
146
|
|
|
58
147
|
const chunks = [];
|
|
59
148
|
await new Promise((res, rej) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
149
|
+
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));
|
|
63
158
|
});
|
|
64
159
|
|
|
65
160
|
const raw = Buffer.concat(chunks);
|
|
@@ -510,7 +605,7 @@ function encryptPayload(plaintext) {
|
|
|
510
605
|
// ---------- HTTP ----------
|
|
511
606
|
|
|
512
607
|
function httpPost(path, body, waVersion, authHeader) {
|
|
513
|
-
const proxy =
|
|
608
|
+
const proxy = socksProxyUrl();
|
|
514
609
|
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader);
|
|
515
610
|
|
|
516
611
|
return new Promise((resolve, reject) => {
|
|
@@ -841,4 +936,4 @@ async function verifyCode(store, code) {
|
|
|
841
936
|
throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
|
|
842
937
|
}
|
|
843
938
|
|
|
844
|
-
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys };
|
|
939
|
+
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
package/lib/noise.js
CHANGED
|
@@ -444,9 +444,44 @@ class NoiseSocket extends EventEmitter {
|
|
|
444
444
|
|
|
445
445
|
if (tag === 'failure') {
|
|
446
446
|
const attrs = (node && node.attrs) || {};
|
|
447
|
-
const reason = attrs.reason || attrs.location || '401';
|
|
448
|
-
|
|
447
|
+
const reason = String(attrs.reason || attrs.location || '401');
|
|
448
|
+
// Say what the code means. "auth failure: 401" sends people looking
|
|
449
|
+
// for a bug in the client; the credentials were rejected by the
|
|
450
|
+
// server, which is a different thing and has different remedies.
|
|
451
|
+
//
|
|
452
|
+
// The names and numbers are whatsmeow's ConnectFailureReason, which
|
|
453
|
+
// is the only place they are written down — WhatsApp publishes none
|
|
454
|
+
// of this. Codes marked "wipe" are the ones its IsLoggedOut() covers:
|
|
455
|
+
// the session is finished and retrying with it is pointless.
|
|
456
|
+
const FAILURE = {
|
|
457
|
+
'400': { wipe: false, text: 'generic failure.' },
|
|
458
|
+
'401': { wipe: true, text: 'logged out. The session was unpaired ' +
|
|
459
|
+
'server-side — nothing in the session file can revive it. ' +
|
|
460
|
+
'For a linked device this means it was removed under ' +
|
|
461
|
+
'Linked Devices; for a registered number it means the ' +
|
|
462
|
+
'registration was taken over, usually by verifying the ' +
|
|
463
|
+
'same number on the official app.' },
|
|
464
|
+
'402': { wipe: false, text: 'temporarily banned.' },
|
|
465
|
+
'403': { wipe: true, text: "the account's primary device is gone " +
|
|
466
|
+
'— it is no longer a multi-device account.' },
|
|
467
|
+
'405': { wipe: true, text: 'client outdated — the announced ' +
|
|
468
|
+
'version is not one the server accepts.' },
|
|
469
|
+
'406': { wipe: true, text: 'banned.' },
|
|
470
|
+
'409': { wipe: true, text: 'bad user agent — the client identified ' +
|
|
471
|
+
'itself in a way the server rejects.' },
|
|
472
|
+
'413': { wipe: false, text: 'auth token expired.' },
|
|
473
|
+
'414': { wipe: false, text: 'auth token invalid.' },
|
|
474
|
+
'415': { wipe: false, text: 'not found.' },
|
|
475
|
+
'500': { wipe: false, text: 'server error — worth retrying.' },
|
|
476
|
+
'503': { wipe: false, text: 'service unavailable — worth retrying.' }
|
|
477
|
+
};
|
|
478
|
+
const info = FAILURE[reason];
|
|
479
|
+
const err = new Error('WhatsApp auth failure ' + reason +
|
|
480
|
+
(info ? ' — ' + info.text : ''));
|
|
449
481
|
err.code = reason;
|
|
482
|
+
// True when the session is spent and the caller should register or
|
|
483
|
+
// link again rather than retry.
|
|
484
|
+
err.loggedOut = !!(info && info.wipe);
|
|
450
485
|
if (this._connectReject) {
|
|
451
486
|
this._connectReject(err);
|
|
452
487
|
this._connectResolve = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.9",
|
|
4
4
|
"description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
|
|
5
5
|
"author": "Kunboruto50",
|
|
6
6
|
"main": "index.js",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"ws": "^8.21.1"
|
|
52
52
|
},
|
|
53
53
|
"optionalDependencies": {
|
|
54
|
-
"jimp": "^1.6.0"
|
|
54
|
+
"jimp": "^1.6.0",
|
|
55
|
+
"socks": "^2.8.9"
|
|
55
56
|
}
|
|
56
57
|
}
|