@eryxenx/fca 1.0.5 → 1.0.7
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/README.md +11 -4
- package/module/config.js +4 -1
- package/module/loginHelper.js +5 -2
- package/package.json +1 -1
- package/src/api/action/refreshFb_dtsg.js +2 -3
- package/src/api/messaging/markAsDelivered.js +12 -0
- package/src/api/messaging/markAsRead.js +33 -11
- package/src/api/socket/core/connectMqtt.js +9 -2
- package/src/api/socket/core/getSeqID.js +50 -40
- package/src/api/socket/e2ee/index.js +81 -19
- package/src/api/socket/e2ee/native/lib/index.mjs +43 -15
- package/src/api/socket/e2ee/native/nativeMediaBridge.js +40 -0
- package/src/api/socket/e2ee/vendor/fme/dist/index.cjs +5 -5
- package/src/api/socket/listenMqtt.js +38 -19
- package/src/utils/headers.js +45 -10
- package/src/utils/loginParser/extractDtsg.js +44 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ NEXCA MQTT · Signal Protocol E2EE (mautrix-go powered) · sessionGuard · 90+ A
|
|
|
18
18
|
## ⚡ Why @eryxenx/fca?
|
|
19
19
|
|
|
20
20
|
- ✅ **NEXCA MQTT** — stable connection core, autoReconnect, jitter
|
|
21
|
-
- ✅ **Signal Protocol E2EE** — Facebook real encrypted conversations support. Reliable media/reaction/unsend delivery is powered by a bundled **mautrix-go / mautrix-meta** native engine (© Tulir Asokan, MPL-2.0) — NEXCA's own hand-written E2EE encoder produced protocol-valid messages that Facebook accepted but Messenger clients failed to render for attachments/reactions/unsend, so this fork routes those through the native engine instead. Text messaging still uses the original engine (already reliable).
|
|
21
|
+
- ✅ **Signal Protocol E2EE** — Facebook real encrypted conversations support. Reliable media/reaction/unsend delivery is powered by a bundled **mautrix-go / mautrix-meta** native engine (© Tulir Asokan, MPL-2.0) — NEXCA's own hand-written E2EE encoder produced protocol-valid messages that Facebook accepted but Messenger clients failed to render for attachments/reactions/unsend, so this fork routes those through the native engine instead. Text messaging still uses the original engine (already reliable). The native engine is opt-in (`FCA_E2EE_NATIVE=1`) — see [Native media engine](#native-media-engine).
|
|
22
22
|
- ✅ **sessionGuard** — appstate corruption and silent logout protection, auto-backup
|
|
23
23
|
- ✅ **sendBroadcast** — rate-limited multi-thread broadcast
|
|
24
24
|
- ✅ **Fixed MQTT subscribe race condition** — no more "Connection refused: No subscription existed"
|
|
@@ -35,7 +35,7 @@ NEXCA MQTT · Signal Protocol E2EE (mautrix-go powered) · sessionGuard · 90+ A
|
|
|
35
35
|
- ✅ Full Messenger API — messages, reactions, attachments, stickers, polls, pins
|
|
36
36
|
- ✅ NEXCA MQTT — stable connection, autoReconnect, jitter, isActiveClient guard
|
|
37
37
|
- ✅ E2EE — Signal Protocol encrypted threads, auto-connects after login (listenE2EE, e2ee.*)
|
|
38
|
-
- ✅ E2EE media engine — native mautrix-go engine for reliable image/video/audio/document sending, reactions, and unsend in encrypted threads (falls back to the built-in JS engine automatically)
|
|
38
|
+
- ✅ E2EE media engine — native mautrix-go engine (opt-in via `FCA_E2EE_NATIVE=1`) for reliable image/video/audio/document sending, reactions, and unsend in encrypted threads (falls back to the built-in JS engine automatically)
|
|
39
39
|
- ✅ E2EE incoming media — attachments on encrypted messages (including replies) are auto-decrypted and served locally, so existing commands work unmodified
|
|
40
40
|
- ✅ sessionGuard — appstate auto-save, corruption guard, .bak backup
|
|
41
41
|
- ✅ sendBroadcast — parallel/sequential multi-thread sending with rate limit
|
|
@@ -53,7 +53,9 @@ npm install @eryxenx/fca
|
|
|
53
53
|
|
|
54
54
|
Node.js >= 18 required.
|
|
55
55
|
|
|
56
|
-
The E2EE media engine ships a precompiled native binary (`src/api/socket/e2ee/native/build/`) for **Linux (.so)** and **Windows (.dll)**, loaded via the `koffi` FFI. `npm install` pulls in `koffi` and `yumi-json-bigint` automatically — no extra setup needed.
|
|
56
|
+
The E2EE media engine ships a precompiled native binary (`src/api/socket/e2ee/native/build/`) for **Linux (.so)** and **Windows (.dll)**, loaded via the `koffi` FFI. `npm install` pulls in `koffi` and `yumi-json-bigint` automatically — no extra setup needed. Note the native engine only runs when `FCA_E2EE_NATIVE=1` is set (see [Native media engine](#native-media-engine)); on platforms other than Linux/Windows x64 (e.g. macOS, ARM) the binary fails to load and E2EE media/reactions/unsend automatically fall back to the built-in JS engine (text messaging is unaffected either way).
|
|
57
|
+
|
|
58
|
+
The E2EE socket's User-Agent defaults to desktop Windows Chrome 139 to match the rest of the client; override with `FCA_E2EE_UA` if you run with a different UA.
|
|
57
59
|
|
|
58
60
|
---
|
|
59
61
|
|
|
@@ -179,7 +181,12 @@ await api.e2ee.disconnect();
|
|
|
179
181
|
|
|
180
182
|
### Native media engine
|
|
181
183
|
|
|
182
|
-
Sending image/video/audio/document attachments, reactions, and unsend in E2EE threads
|
|
184
|
+
Sending image/video/audio/document attachments, reactions, and unsend in E2EE threads can be routed through a precompiled **mautrix-go / mautrix-meta** binary (`src/api/socket/e2ee/native/`) via the `koffi` FFI, instead of this project's own hand-written Signal Protocol encoder. This project's own encoder builds protocol-valid messages that Facebook's servers accept, but real Messenger clients silently fail to render — the native engine avoids that entirely.
|
|
185
|
+
|
|
186
|
+
**⚠️ The native engine is DISABLED by default.** Both the vendored JS engine and the native engine register with Facebook as the *same* E2EE device (same device id), and Facebook's server keeps only one live E2EE socket per device — so when both run at once they kick each other off in a loop (a fresh handshake every few seconds, which looks like automated behavior to Facebook). The vendored JS engine is therefore given exclusive use of the socket unless you opt in:
|
|
187
|
+
|
|
188
|
+
- Set `FCA_E2EE_NATIVE=1` in the environment to enable the native engine. With it on you get E2EE group text receive/send, native media sends, native reactions/unsend/edit/markRead — but you also accept the device-kick socket churn described above (the JS engine reconnects with exponential backoff, so it stays usable, just noisy).
|
|
189
|
+
- With it off (default): E2EE DM text + DM media work through the vendored engine. E2EE **group** send/receive, and E2EE markRead, are unavailable; reactions/unsend/edit in E2EE DMs fall back to the vendored engine.
|
|
183
190
|
|
|
184
191
|
- Text messages always use @eryxenx/fca's own E2EE engine (already reliable, no native dependency).
|
|
185
192
|
- If the native engine fails to load or a send fails (unsupported platform, binary mismatch, etc.), @eryxenx/fca automatically falls back to its own engine and logs `[native-media] ... falling back to legacy vendor engine`.
|
package/module/config.js
CHANGED
|
@@ -4,7 +4,10 @@ const logger = require("../func/logger");
|
|
|
4
4
|
const defaultConfig = {
|
|
5
5
|
autoUpdate: true,
|
|
6
6
|
mqtt: { enabled: true, reconnectInterval: 3600 },
|
|
7
|
-
|
|
7
|
+
// Opt-in only: auto re-login with credentials (via the third-party API)
|
|
8
|
+
// after a checkpoint/session failure is itself a strong suspicious-login
|
|
9
|
+
// signal and invalidates every other live session on the account.
|
|
10
|
+
autoLogin: false,
|
|
8
11
|
apiServer: "https://minhdong.site",
|
|
9
12
|
apiKey: "",
|
|
10
13
|
credentials: { email: "", password: "", twofactor: "" },
|
package/module/loginHelper.js
CHANGED
|
@@ -1283,14 +1283,17 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
|
|
|
1283
1283
|
api._listenMqttRaw = api.listenMqtt;
|
|
1284
1284
|
api.listen = api.listenMqtt;
|
|
1285
1285
|
}
|
|
1286
|
-
if (api.refreshFb_dtsg) {
|
|
1287
|
-
setInterval(function () {
|
|
1286
|
+
if (api.refreshFb_dtsg && !ctxMain._dtsgRefreshInterval) {
|
|
1287
|
+
ctxMain._dtsgRefreshInterval = setInterval(function () {
|
|
1288
1288
|
api.refreshFb_dtsg().then(function () {
|
|
1289
1289
|
logger("Successfully refreshed fb_dtsg");
|
|
1290
1290
|
}).catch(function () {
|
|
1291
1291
|
logger("An error occurred while refreshing fb_dtsg", "error");
|
|
1292
1292
|
});
|
|
1293
1293
|
}, 86400000);
|
|
1294
|
+
// Don't stack a new interval per login, and don't keep the process
|
|
1295
|
+
// alive just for this.
|
|
1296
|
+
if (ctxMain._dtsgRefreshInterval.unref) ctxMain._dtsgRefreshInterval.unref();
|
|
1294
1297
|
}
|
|
1295
1298
|
// ✅ nexca core features register
|
|
1296
1299
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/fca",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — send messages, media, reactions & more in encrypted chats, hassle-free",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const log = require("../../../func/logAdapter");
|
|
4
|
-
const { getFrom } = require("../../utils/constants");
|
|
5
4
|
const { get } = require("../../utils/request")
|
|
6
5
|
const { getType } = require("../../utils/format");
|
|
6
|
+
const { extractDtsg } = require("../../utils/loginParser/extractDtsg");
|
|
7
7
|
module.exports = function (defaultFuncs, api, ctx) {
|
|
8
8
|
return function refreshFb_dtsg(obj, callback) {
|
|
9
9
|
if (typeof obj === "function") {
|
|
@@ -24,8 +24,7 @@ module.exports = function (defaultFuncs, api, ctx) {
|
|
|
24
24
|
}
|
|
25
25
|
if (Object.keys(obj).length === 0) {
|
|
26
26
|
get("https://www.facebook.com/", ctx.jar, null, ctx.globalOptions, { noRef: true }).then(({ data }) => {
|
|
27
|
-
const fb_dtsg =
|
|
28
|
-
const jazoest = getFrom(data, "jazoest=", '",');
|
|
27
|
+
const { fb_dtsg, jazoest } = extractDtsg(data);
|
|
29
28
|
if (!fb_dtsg) throw new Error("Could not find fb_dtsg in HTML after requesting Facebook.");
|
|
30
29
|
Object.assign(ctx, { fb_dtsg, jazoest });
|
|
31
30
|
callback(null, {
|
|
@@ -24,6 +24,18 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
24
24
|
return callback("Error: messageID or threadID is not defined");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Modern Messenger clients do not POST delivery receipts to the
|
|
28
|
+
// Mercury-era delivery_receipts.php endpoint — delivery is implicit in
|
|
29
|
+
// the MQTT sync channel. Calling that endpoint once per incoming message
|
|
30
|
+
// (which GoatBot's handlerEvents does) produced a constant stream of
|
|
31
|
+
// request shapes no real client emits. When MQTT is live we resolve
|
|
32
|
+
// immediately; the legacy HTTP call is kept only as a fallback for
|
|
33
|
+
// callers that explicitly use this API without an active listener.
|
|
34
|
+
if (ctx.mqttClient && ctx.mqttClient.connected) {
|
|
35
|
+
callback();
|
|
36
|
+
return returnPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const form = {};
|
|
28
40
|
|
|
29
41
|
form["message_ids[0]"] = messageID;
|
|
@@ -60,18 +60,38 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
60
60
|
return null;
|
|
61
61
|
} else {
|
|
62
62
|
try {
|
|
63
|
-
if (ctx.mqttClient) {
|
|
63
|
+
if (ctx.mqttClient && ctx.mqttClient.connected) {
|
|
64
|
+
// Modern clients send read state as a label-21 ls_req task (the
|
|
65
|
+
// same shape sendMessage.js already uses for its read watermark),
|
|
66
|
+
// not the legacy /mark_thread topic or the Mercury-era
|
|
67
|
+
// change_read_status.php endpoint.
|
|
68
|
+
const requestId = Math.floor(100 + Math.random() * 900);
|
|
69
|
+
const epoch = (BigInt(Date.now()) << 22n).toString();
|
|
70
|
+
const content = {
|
|
71
|
+
app_id: "2220391788200892",
|
|
72
|
+
payload: JSON.stringify({
|
|
73
|
+
tasks: [
|
|
74
|
+
{
|
|
75
|
+
label: "21",
|
|
76
|
+
payload: JSON.stringify({
|
|
77
|
+
thread_id: String(threadID),
|
|
78
|
+
last_read_watermark_ts: Date.now(),
|
|
79
|
+
sync_group: 1
|
|
80
|
+
}),
|
|
81
|
+
queue_name: String(threadID),
|
|
82
|
+
task_id: Math.floor(100 + Math.random() * 900),
|
|
83
|
+
failure_count: null
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
epoch_id: epoch,
|
|
87
|
+
version_id: "24804310205905615",
|
|
88
|
+
data_trace_id: "#" + Buffer.from(String(Math.random())).toString("base64").replace(/=+$/, "")
|
|
89
|
+
}),
|
|
90
|
+
request_id: requestId,
|
|
91
|
+
type: 3
|
|
92
|
+
};
|
|
64
93
|
const err = await new Promise(r =>
|
|
65
|
-
ctx.mqttClient.publish(
|
|
66
|
-
"/mark_thread",
|
|
67
|
-
JSON.stringify({
|
|
68
|
-
threadID,
|
|
69
|
-
mark: "read",
|
|
70
|
-
state: read
|
|
71
|
-
}),
|
|
72
|
-
{ qos: 1, retain: false },
|
|
73
|
-
r
|
|
74
|
-
)
|
|
94
|
+
ctx.mqttClient.publish("/ls_req", JSON.stringify(content), { qos: 1, retain: false }, r)
|
|
75
95
|
);
|
|
76
96
|
if (err) throw err;
|
|
77
97
|
} else {
|
|
@@ -83,6 +103,8 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
83
103
|
callback(e);
|
|
84
104
|
return e;
|
|
85
105
|
}
|
|
106
|
+
callback();
|
|
107
|
+
return null;
|
|
86
108
|
}
|
|
87
109
|
};
|
|
88
110
|
};
|
|
@@ -38,7 +38,10 @@ module.exports = function createListenMqtt(deps) {
|
|
|
38
38
|
return /No subscription existed|client disconnecting|socket hang up|ECONNRESET/i.test(msg || "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// Default to online unless explicitly disabled — GoatBot's optionsFca
|
|
42
|
+
// has no `online` key, which used to make this undefined (falsy), so the
|
|
43
|
+
// account sat permanently "inactive" while actively sending messages.
|
|
44
|
+
const chatOn = ctx.globalOptions.online !== false;
|
|
42
45
|
const sessionID = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) + 1;
|
|
43
46
|
const username = {
|
|
44
47
|
u: ctx.userID, s: sessionID, chat_on: chatOn, fg: false, d: ctx.clientId,
|
|
@@ -71,7 +74,10 @@ module.exports = function createListenMqtt(deps) {
|
|
|
71
74
|
Upgrade: "websocket",
|
|
72
75
|
"Sec-WebSocket-Version": "13",
|
|
73
76
|
"Accept-Encoding": "gzip, deflate, br",
|
|
74
|
-
|
|
77
|
+
// Must match the HTTP layer's Accept-Language (src/utils/headers.js)
|
|
78
|
+
// — a hardcoded different value here made the WS handshake and the
|
|
79
|
+
// HTTP requests disagree about the same "browser".
|
|
80
|
+
"Accept-Language": ctx.globalOptions.acceptLanguage || "en-US,en;q=0.9,vi;q=0.8",
|
|
75
81
|
"Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits"
|
|
76
82
|
},
|
|
77
83
|
origin: "https://www.facebook.com",
|
|
@@ -187,6 +193,7 @@ module.exports = function createListenMqtt(deps) {
|
|
|
187
193
|
|
|
188
194
|
mqttClient.on("message", function (topic, message) {
|
|
189
195
|
if (ctx._ending) return; // Ignore messages if ending
|
|
196
|
+
ctx.lastMqttActivity = Date.now(); // packet-level liveness for the heartbeat
|
|
190
197
|
try {
|
|
191
198
|
let jsonMessage = Buffer.isBuffer(message) ? Buffer.from(message).toString() : message;
|
|
192
199
|
try {
|
|
@@ -48,7 +48,10 @@ async function tryAutoLogin(logger, config, ctx, defaultFuncs) {
|
|
|
48
48
|
const password = config.credentials?.password || config.password;
|
|
49
49
|
const twofactor = config.credentials?.twofactor || config.twofactor || null;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Opt-in, consistent with loginHelper: auto re-login only runs when the
|
|
52
|
+
// user explicitly sets autoLogin: true. (Previously `!== false`, so it ran
|
|
53
|
+
// for everyone who never set the flag.)
|
|
54
|
+
if ((config.autoLogin !== true && config.autoLogin !== "true") || !email || !password) {
|
|
52
55
|
return null;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -210,7 +213,10 @@ async function tryAutoLogin(logger, config, ctx, defaultFuncs) {
|
|
|
210
213
|
logger(`getSeqID: web session refresh failed - ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
211
214
|
}
|
|
212
215
|
|
|
213
|
-
|
|
216
|
+
// NOTE: there was a `cookies` ReferenceError here (no such variable
|
|
217
|
+
// in scope) which made every auto-login attempt throw into the catch
|
|
218
|
+
// below and get reported as failed even when it succeeded.
|
|
219
|
+
return { ...result };
|
|
214
220
|
}
|
|
215
221
|
}
|
|
216
222
|
|
|
@@ -226,7 +232,6 @@ module.exports = function createGetSeqID(deps) {
|
|
|
226
232
|
const { listenMqtt, logger, emitAuth } = deps;
|
|
227
233
|
|
|
228
234
|
return function getSeqID(defaultFuncs, api, ctx, globalCallback, form, retryCount = 0) {
|
|
229
|
-
const MAX_RETRIES = 3;
|
|
230
235
|
const RETRY_DELAY = 2000;
|
|
231
236
|
ctx.t_mqttCalled = false;
|
|
232
237
|
|
|
@@ -267,55 +272,60 @@ module.exports = function createGetSeqID(deps) {
|
|
|
267
272
|
const detail = (err && err.detail && err.detail.message) ? ` | detail=${err.detail.message}` : "";
|
|
268
273
|
const msg = ((err && err.error) || (err && err.message) || String(err || "")) + detail;
|
|
269
274
|
|
|
275
|
+
// Definitive server-side session invalidation: error 1357001
|
|
276
|
+
// ("Log in to continue") or an explicit login block. The session is
|
|
277
|
+
// dead, not desynced — retrying within seconds (and especially
|
|
278
|
+
// re-logging-in with credentials) just hammers Facebook with
|
|
279
|
+
// failed-auth requests from an already-flagged account and makes the
|
|
280
|
+
// account-level risk score worse. Stop cleanly instead and surface
|
|
281
|
+
// account_inactive so the operator can step in manually.
|
|
282
|
+
const isDefinitive =
|
|
283
|
+
/1357001|blocked the login|checkpoint\/block/i.test(msg) ||
|
|
284
|
+
(err && err.res && err.res.error === 1357001);
|
|
285
|
+
if (isDefinitive) {
|
|
286
|
+
logger(`getSeqID: session definitively invalidated (${msg}) — NOT retrying. Update appstate manually.`, "error");
|
|
287
|
+
return emitAuth(ctx, api, globalCallback, /blocked/i.test(msg) ? "login_blocked" : "not_logged_in", msg);
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
// Check if this is an auth-related error
|
|
271
|
-
const isAuthError = /Not logged in|no sync_sequence_id found|
|
|
291
|
+
const isAuthError = /Not logged in|no sync_sequence_id found|401|403/i.test(msg);
|
|
272
292
|
|
|
273
|
-
// For auth errors
|
|
293
|
+
// For transient auth errors (cookie/dtsg desync), do ONE
|
|
294
|
+
// refresh-and-retry, then give up instead of looping.
|
|
274
295
|
if (isAuthError) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
logger("getSeqID: refreshing session before retry...", "info");
|
|
285
|
-
const { get } = require("../../../utils/request");
|
|
286
|
-
const { saveCookies } = require("../../../utils/client");
|
|
287
|
-
await get("https://www.facebook.com/", ctx.jar, null, ctx.globalOptions, ctx).then(saveCookies(ctx.jar));
|
|
288
|
-
} catch (refreshErr) {
|
|
289
|
-
logger(`getSeqID: session refresh failed: ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
290
|
-
}
|
|
296
|
+
if (retryCount < 1 && ctx.loggedIn) {
|
|
297
|
+
logger(`getSeqID: auth error (${msg}) — one session refresh + retry`, "warn");
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
|
299
|
+
try {
|
|
300
|
+
const { get } = require("../../../utils/request");
|
|
301
|
+
const { saveCookies } = require("../../../utils/client");
|
|
302
|
+
await get("https://www.facebook.com/", ctx.jar, null, ctx.globalOptions, ctx).then(saveCookies(ctx.jar));
|
|
303
|
+
} catch (refreshErr) {
|
|
304
|
+
logger(`getSeqID: session refresh failed: ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
291
305
|
}
|
|
292
|
-
|
|
293
306
|
return getSeqID(defaultFuncs, api, ctx, globalCallback, form, retryCount + 1);
|
|
294
307
|
}
|
|
295
308
|
|
|
296
|
-
//
|
|
297
|
-
|
|
309
|
+
// Refresh+retry didn't help. Auto re-login only when explicitly
|
|
310
|
+
// enabled in fca-config.json (autoLogin: true).
|
|
298
311
|
const config = getConfig();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
312
|
+
if (config.autoLogin === true || config.autoLogin === "true") {
|
|
313
|
+
logger("getSeqID: attempting auto re-login (autoLogin enabled)...", "warn");
|
|
314
|
+
const loginResult = await tryAutoLogin(logger, config, ctx, defaultFuncs);
|
|
315
|
+
if (loginResult) {
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
317
|
+
return getSeqID(defaultFuncs, api, ctx, globalCallback, form, 0);
|
|
318
|
+
}
|
|
306
319
|
}
|
|
307
320
|
|
|
308
|
-
|
|
309
|
-
if (/blocked/i.test(msg)) {
|
|
310
|
-
return emitAuth(ctx, api, globalCallback, "login_blocked", msg);
|
|
311
|
-
}
|
|
312
|
-
if (/Not logged in/i.test(msg)) {
|
|
313
|
-
return emitAuth(ctx, api, globalCallback, "not_logged_in", msg);
|
|
314
|
-
}
|
|
321
|
+
return emitAuth(ctx, api, globalCallback, "not_logged_in", msg);
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
|
|
318
|
-
|
|
324
|
+
// Non-auth error (network hiccup, parse failure, ...): do NOT kill
|
|
325
|
+
// the session over it. Rethrow so the caller's autoReconnect retry
|
|
326
|
+
// (listenMqtt getSeqIDWrapper) handles it with its normal backoff.
|
|
327
|
+
logger(`getSeqID error (transient, will retry via reconnect): ${msg}`, "error");
|
|
328
|
+
throw err instanceof Error ? err : new Error(msg);
|
|
319
329
|
});
|
|
320
330
|
};
|
|
321
331
|
};
|
|
@@ -133,31 +133,52 @@ class E2EEBridge {
|
|
|
133
133
|
// Auto-reconnect the Noise WebSocket whenever it drops unexpectedly.
|
|
134
134
|
// `this.connected` stays true through reconnect cycles; it's only set
|
|
135
135
|
// false by an explicit api.e2ee.disconnect() call.
|
|
136
|
+
//
|
|
137
|
+
// Exponential backoff is critical here: a fixed 5s retry means that if
|
|
138
|
+
// FB is deliberately kicking the socket, we end up doing a fresh Noise
|
|
139
|
+
// handshake + prekey sync every ~11s — a loud automated-behavior signal
|
|
140
|
+
// on the server side. Back off 5s -> 10s -> 20s ... capped at 5min,
|
|
141
|
+
// and reset the counter after the connection has been stable for a
|
|
142
|
+
// while.
|
|
136
143
|
const _self = this;
|
|
144
|
+
_self._e2eeReconnectDelay = 5000;
|
|
145
|
+
_self._e2eeReconnectTimer = null;
|
|
146
|
+
_self._e2eeStableTimer = null;
|
|
147
|
+
function _markE2EEStable() {
|
|
148
|
+
_self._e2eeReconnectDelay = 5000;
|
|
149
|
+
if (_self._e2eeStableTimer) { clearTimeout(_self._e2eeStableTimer); _self._e2eeStableTimer = null; }
|
|
150
|
+
}
|
|
151
|
+
this.client.onEvent("e2ee_connected", function _onE2EEConnected() {
|
|
152
|
+
// Only count it as stable if it survived a bit — the reset timer
|
|
153
|
+
// is started below on reconnect success instead, since this event
|
|
154
|
+
// also fires on the very first connect.
|
|
155
|
+
});
|
|
137
156
|
this.client.onEvent("disconnected", function _onE2EEDisconnected() {
|
|
138
157
|
if (!_self.connected) return; // intentional disconnect — skip
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
if (_self._e2eeReconnectTimer) return; // a reconnect is already queued
|
|
159
|
+
if (_self._e2eeStableTimer) { clearTimeout(_self._e2eeStableTimer); _self._e2eeStableTimer = null; }
|
|
160
|
+
const delay = _self._e2eeReconnectDelay;
|
|
161
|
+
logger.warn("E2EE", "Noise WebSocket disconnected — reconnecting in " + Math.round(delay / 1000) + "s...");
|
|
162
|
+
_self._e2eeReconnectTimer = setTimeout(async function () {
|
|
163
|
+
_self._e2eeReconnectTimer = null;
|
|
141
164
|
if (!_self.connected) return;
|
|
142
165
|
try {
|
|
143
166
|
await _self.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
|
|
144
167
|
logger.success("E2EE", "E2EE WebSocket reconnected.");
|
|
168
|
+
// Next failure doubles the wait; reset only after the
|
|
169
|
+
// socket has stayed up for 10 minutes.
|
|
170
|
+
_self._e2eeReconnectDelay = Math.min(_self._e2eeReconnectDelay * 2, 5 * 60 * 1000);
|
|
171
|
+
_self._e2eeStableTimer = setTimeout(_markE2EEStable, 10 * 60 * 1000);
|
|
145
172
|
} catch (err) {
|
|
146
173
|
logger.error("E2EE", "E2EE reconnect failed: " + (err && err.message ? err.message : String(err)));
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
logger.success("E2EE", "E2EE WebSocket reconnected (retry).");
|
|
154
|
-
} catch (err2) {
|
|
155
|
-
logger.error("E2EE", "E2EE reconnect retry failed: " + (err2 && err2.message ? err2.message : String(err2)));
|
|
156
|
-
}
|
|
157
|
-
}, 10000);
|
|
158
|
-
}
|
|
174
|
+
_self._e2eeReconnectDelay = Math.min(_self._e2eeReconnectDelay * 2, 5 * 60 * 1000);
|
|
175
|
+
// Schedule another attempt via the same back-off path.
|
|
176
|
+
_self._e2eeReconnectTimer = setTimeout(function () {
|
|
177
|
+
_self._e2eeReconnectTimer = null;
|
|
178
|
+
if (_self.connected) _onE2EEDisconnected();
|
|
179
|
+
}, _self._e2eeReconnectDelay);
|
|
159
180
|
}
|
|
160
|
-
},
|
|
181
|
+
}, delay);
|
|
161
182
|
});
|
|
162
183
|
|
|
163
184
|
// Forward incoming E2EE messages to the registered callback.
|
|
@@ -347,7 +368,23 @@ class E2EEBridge {
|
|
|
347
368
|
// decrypts these correctly, so group messages are received through it
|
|
348
369
|
// instead. DM receiving is untouched — it still goes exclusively
|
|
349
370
|
// through the vendor engine above, unchanged.
|
|
371
|
+
//
|
|
372
|
+
// IMPORTANT trade-off: the native engine and the vendored engine both
|
|
373
|
+
// register as the SAME E2EE device (same jidDevice). Facebook's server
|
|
374
|
+
// only allows ONE live E2EE socket per device, so when the native
|
|
375
|
+
// engine connects, the server kicks the vendored socket, and our
|
|
376
|
+
// reconnect then kicks the native one — an endless kick/ping-pong
|
|
377
|
+
// loop (a fresh Noise handshake every few seconds). Until the two
|
|
378
|
+
// engines can use distinct device ids, the native engine must stay
|
|
379
|
+
// OFF for the vendored socket to stay alive. Opt back in with
|
|
380
|
+
// FCA_E2EE_NATIVE=1 (group E2EE receive + native media sends) if you
|
|
381
|
+
// accept the resulting socket churn.
|
|
382
|
+
const _nativeEnabled = process.env.FCA_E2EE_NATIVE === "1";
|
|
383
|
+
if (!_nativeEnabled) {
|
|
384
|
+
logger.info("E2EE", "[native-group] native engine disabled (FCA_E2EE_NATIVE!=1) — vendored socket kept exclusive to avoid server-side device kicks");
|
|
385
|
+
}
|
|
350
386
|
try {
|
|
387
|
+
if (!_nativeEnabled) throw { _skipNative: true };
|
|
351
388
|
this._seenGroupMsgIds = this._seenGroupMsgIds || new Set();
|
|
352
389
|
// Fire-and-forget: do NOT await this. The native client's own
|
|
353
390
|
// connect (ICDC device registration etc.) can be slow/unpredictable,
|
|
@@ -426,8 +463,12 @@ class E2EEBridge {
|
|
|
426
463
|
(err && err.message ? err.message : String(err)));
|
|
427
464
|
});
|
|
428
465
|
} catch (err) {
|
|
429
|
-
|
|
430
|
-
|
|
466
|
+
if (err && err._skipNative) {
|
|
467
|
+
// intentional — native engine disabled, see note above
|
|
468
|
+
} else {
|
|
469
|
+
logger.error("E2EE", "[native-group] failed to attach native message listener (non-fatal, DM unaffected): " +
|
|
470
|
+
(err && err.message ? err.message : String(err)));
|
|
471
|
+
}
|
|
431
472
|
}
|
|
432
473
|
|
|
433
474
|
return this;
|
|
@@ -470,6 +511,12 @@ class E2EEBridge {
|
|
|
470
511
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
471
512
|
|
|
472
513
|
if (!attachment && isGroup) {
|
|
514
|
+
if (process.env.FCA_E2EE_NATIVE !== "1") {
|
|
515
|
+
// Native engine disabled (default) — it shares the same E2EE
|
|
516
|
+
// device id as the vendored socket and triggers server-side
|
|
517
|
+
// device kicks. Group E2EE text send unavailable in this mode.
|
|
518
|
+
throw new Error("E2EE group text send requires the native engine (FCA_E2EE_NATIVE=1), which is disabled to protect the vendored socket from server-side device kicks.");
|
|
519
|
+
}
|
|
473
520
|
// Vendor's own group Sender-Key encryption is broken (see native-group
|
|
474
521
|
// listener above) — group text sends go through native instead.
|
|
475
522
|
const result = await nativeMediaBridge.sendGroupMessage(this.api.getAppState(), threadId, text, replyToMessageId);
|
|
@@ -546,6 +593,9 @@ class E2EEBridge {
|
|
|
546
593
|
const appState = this.api.getAppState();
|
|
547
594
|
let result;
|
|
548
595
|
try {
|
|
596
|
+
if (process.env.FCA_E2EE_NATIVE !== "1") {
|
|
597
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1) — see native-group note");
|
|
598
|
+
}
|
|
549
599
|
result = await nativeMediaBridge.sendMedia(appState, threadId, mediaType, data, mimeType, {
|
|
550
600
|
caption: text || undefined,
|
|
551
601
|
width: dims ? dims.width : undefined,
|
|
@@ -640,7 +690,11 @@ class E2EEBridge {
|
|
|
640
690
|
|
|
641
691
|
async markRead(threadId, watermarkTs) {
|
|
642
692
|
this.ensureConnected();
|
|
643
|
-
|
|
693
|
+
if (process.env.FCA_E2EE_NATIVE === "1")
|
|
694
|
+
return nativeMediaBridge.markRead(this.api.getAppState(), threadId, watermarkTs);
|
|
695
|
+
// Native disabled: read receipts aren't available through the vendored
|
|
696
|
+
// engine — treat as a no-op rather than waking the native engine.
|
|
697
|
+
return undefined;
|
|
644
698
|
}
|
|
645
699
|
|
|
646
700
|
getThreadIdForMessage(messageId) {
|
|
@@ -657,6 +711,8 @@ class E2EEBridge {
|
|
|
657
711
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
658
712
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
659
713
|
try {
|
|
714
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
715
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1)");
|
|
660
716
|
return await nativeMediaBridge.sendReaction(this.api.getAppState(), threadId, messageId, senderJid, reaction, isGroup);
|
|
661
717
|
} catch (err) {
|
|
662
718
|
if (isGroup) {
|
|
@@ -678,6 +734,8 @@ class E2EEBridge {
|
|
|
678
734
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
679
735
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
680
736
|
try {
|
|
737
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
738
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1)");
|
|
681
739
|
return await nativeMediaBridge.unsendMessage(this.api.getAppState(), threadId, messageId, isGroup);
|
|
682
740
|
} catch (err) {
|
|
683
741
|
if (isGroup) {
|
|
@@ -694,6 +752,8 @@ class E2EEBridge {
|
|
|
694
752
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
695
753
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
696
754
|
if (isGroup) {
|
|
755
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
756
|
+
throw new Error("E2EE group edit requires the native engine (FCA_E2EE_NATIVE=1), which is disabled.");
|
|
697
757
|
// Vendor's group Sender-Key encryption is broken, so route
|
|
698
758
|
// group edits through native (it doesn't need a chatJid — the
|
|
699
759
|
// messageId alone identifies which chat/message to edit).
|
|
@@ -727,10 +787,12 @@ class E2EEBridge {
|
|
|
727
787
|
}
|
|
728
788
|
|
|
729
789
|
async disconnect() {
|
|
790
|
+
this.connected = false; // set first so the disconnect handler won't queue a reconnect
|
|
791
|
+
if (this._e2eeReconnectTimer) { clearTimeout(this._e2eeReconnectTimer); this._e2eeReconnectTimer = null; }
|
|
792
|
+
if (this._e2eeStableTimer) { clearTimeout(this._e2eeStableTimer); this._e2eeStableTimer = null; }
|
|
730
793
|
if (this.client) {
|
|
731
794
|
try { await this.client.disconnect(); } catch (_) {}
|
|
732
795
|
}
|
|
733
|
-
this.connected = false;
|
|
734
796
|
logger.info("E2EE", "E2EE disconnected.");
|
|
735
797
|
}
|
|
736
798
|
}
|
|
@@ -98,24 +98,52 @@ function call(fn, payload) {
|
|
|
98
98
|
return data.data;
|
|
99
99
|
}
|
|
100
100
|
__name(call, "call");
|
|
101
|
+
|
|
102
|
+
// Real async FFI: koffi's .async runs the native call on libuv's threadpool
|
|
103
|
+
// instead of the main thread. The old callAsync() only deferred the call with
|
|
104
|
+
// setTimeout(0) but still executed it synchronously on the main thread, which
|
|
105
|
+
// froze the event loop for the entire duration of long native calls (media
|
|
106
|
+
// sends were observed blocking for 80+ seconds). A frozen event loop means
|
|
107
|
+
// MQTT keepalive pings never go out, so the server kills the socket
|
|
108
|
+
// (close 1000) and the session starts looking erratic to Facebook.
|
|
109
|
+
var _asyncFns = {};
|
|
110
|
+
function getAsyncFn(fn) {
|
|
111
|
+
if (!_asyncFns[fn]) {
|
|
112
|
+
// koffi 3.x: async is a member of the decoded function and takes a
|
|
113
|
+
// callback as the last argument (runs the native call on a libuv worker
|
|
114
|
+
// thread instead of blocking the main thread).
|
|
115
|
+
const bound = fns[fn];
|
|
116
|
+
_asyncFns[fn] = (input) => new Promise((resolve, reject) => {
|
|
117
|
+
bound.async(input, (err, res) => (err ? reject(err) : resolve(res)));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return _asyncFns[fn];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Serialize all native calls through a single FIFO queue. The Go side keeps
|
|
124
|
+
// per-handle state (device tables, socket, ratchet state) and the JS side
|
|
125
|
+
// shares one handle across every call, so parallel native invocations would
|
|
126
|
+
// interleave unpredictably. Serial + async gives non-blocking execution
|
|
127
|
+
// without any concurrency hazards.
|
|
128
|
+
var _nativeQueue = Promise.resolve();
|
|
101
129
|
function callAsync(fn, payload) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
reject(err);
|
|
109
|
-
}
|
|
110
|
-
}, 0);
|
|
130
|
+
const run = _nativeQueue.then(async () => {
|
|
131
|
+
const input = JSONBigNative.stringify(payload);
|
|
132
|
+
const out = await getAsyncFn(fn)(input);
|
|
133
|
+
const data = JSONBigNative.parse(out);
|
|
134
|
+
if (!data.ok) throw new Error(data.error || "Unknown error");
|
|
135
|
+
return data.data;
|
|
111
136
|
});
|
|
137
|
+
// A rejected call must not poison the queue for subsequent calls.
|
|
138
|
+
_nativeQueue = run.catch(() => {});
|
|
139
|
+
return run;
|
|
112
140
|
}
|
|
113
141
|
__name(callAsync, "callAsync");
|
|
114
142
|
var native = {
|
|
115
|
-
newClient: /* @__PURE__ */ __name((cfg) =>
|
|
116
|
-
connect: /* @__PURE__ */ __name((handle) =>
|
|
143
|
+
newClient: /* @__PURE__ */ __name((cfg) => callAsync("MxNewClient", cfg), "newClient"),
|
|
144
|
+
connect: /* @__PURE__ */ __name((handle) => callAsync("MxConnect", { handle }), "connect"),
|
|
117
145
|
connectE2EE: /* @__PURE__ */ __name((handle) => callAsync("MxConnectE2EE", { handle }), "connectE2EE"),
|
|
118
|
-
disconnect: /* @__PURE__ */ __name((handle) =>
|
|
146
|
+
disconnect: /* @__PURE__ */ __name((handle) => callAsync("MxDisconnect", { handle }), "disconnect"),
|
|
119
147
|
isConnected: /* @__PURE__ */ __name((handle) => call("MxIsConnected", { handle }), "isConnected"),
|
|
120
148
|
sendMessage: /* @__PURE__ */ __name((handle, options) => callAsync("MxSendMessage", { handle, options }), "sendMessage"),
|
|
121
149
|
sendReaction: /* @__PURE__ */ __name((handle, threadId, messageId, emoji) => callAsync("MxSendReaction", { handle, threadId, messageId, emoji }), "sendReaction"),
|
|
@@ -257,7 +285,7 @@ var Client = class extends EventEmitter {
|
|
|
257
285
|
* @returns User info and initial data
|
|
258
286
|
*/
|
|
259
287
|
async connect() {
|
|
260
|
-
const { handle } = native.newClient({
|
|
288
|
+
const { handle } = await native.newClient({
|
|
261
289
|
cookies: this.cookies,
|
|
262
290
|
platform: this.options.platform,
|
|
263
291
|
devicePath: this.options.devicePath,
|
|
@@ -266,7 +294,7 @@ var Client = class extends EventEmitter {
|
|
|
266
294
|
logLevel: this.options.logLevel
|
|
267
295
|
});
|
|
268
296
|
this.handle = handle;
|
|
269
|
-
const result = native.connect(handle);
|
|
297
|
+
const result = await native.connect(handle);
|
|
270
298
|
this._user = result.user;
|
|
271
299
|
this._initialData = result.initialData;
|
|
272
300
|
this.startEventLoop();
|
|
@@ -295,7 +323,7 @@ var Client = class extends EventEmitter {
|
|
|
295
323
|
async disconnect() {
|
|
296
324
|
this.stopEventLoop();
|
|
297
325
|
if (this.handle) {
|
|
298
|
-
native.disconnect(this.handle);
|
|
326
|
+
await native.disconnect(this.handle);
|
|
299
327
|
this.handle = null;
|
|
300
328
|
}
|
|
301
329
|
}
|
|
@@ -20,10 +20,45 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const path = require("path");
|
|
23
|
+
const fs = require("fs");
|
|
23
24
|
const logger = require("../../../../utils/nexca-logger");
|
|
24
25
|
|
|
25
26
|
const NATIVE_CALL_TIMEOUT_MS = 15000;
|
|
26
27
|
|
|
28
|
+
// Where the native client's E2EE device identity is persisted between
|
|
29
|
+
// restarts. With memory-only storage every bot restart registers a brand-new
|
|
30
|
+
// E2EE device with Facebook — on an already-flagged account, "yet another
|
|
31
|
+
// new device" per restart is exactly the kind of signal that keeps the
|
|
32
|
+
// account under scrutiny.
|
|
33
|
+
function _deviceDataPath() {
|
|
34
|
+
return path.join(process.cwd(), ".nexca", "native_e2ee_device.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _loadDeviceData() {
|
|
38
|
+
try {
|
|
39
|
+
const p = _deviceDataPath();
|
|
40
|
+
if (fs.existsSync(p)) {
|
|
41
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (parsed && typeof parsed.deviceData === "string" && parsed.deviceData.length) {
|
|
44
|
+
return parsed.deviceData;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (_) { /* corrupted/absent store — start fresh */ }
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _saveDeviceData(client) {
|
|
52
|
+
try {
|
|
53
|
+
if (!client || typeof client.getDeviceData !== "function") return;
|
|
54
|
+
const deviceData = client.getDeviceData();
|
|
55
|
+
if (!deviceData || typeof deviceData !== "string") return;
|
|
56
|
+
const p = _deviceDataPath();
|
|
57
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
58
|
+
fs.writeFileSync(p, JSON.stringify({ deviceData, savedAt: Date.now() }), "utf8");
|
|
59
|
+
} catch (_) { /* best-effort persistence */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
/**
|
|
28
63
|
* Wraps a native-engine call with a hard timeout. A hung native call (e.g.
|
|
29
64
|
* a stuck getDeviceList/usync query) was observed blocking the whole
|
|
@@ -95,9 +130,12 @@ async function getClient(appState) {
|
|
|
95
130
|
throw new Error("Native E2EE media engine: c_user/xs cookies missing from appState");
|
|
96
131
|
}
|
|
97
132
|
|
|
133
|
+
const storedDeviceData = _loadDeviceData();
|
|
134
|
+
|
|
98
135
|
_clientInstance = new mod.Client(cookies, {
|
|
99
136
|
enableE2EE: true,
|
|
100
137
|
e2eeMemoryOnly: true,
|
|
138
|
+
deviceData: storedDeviceData || undefined,
|
|
101
139
|
autoReconnect: true,
|
|
102
140
|
logLevel: "none"
|
|
103
141
|
});
|
|
@@ -105,6 +143,7 @@ async function getClient(appState) {
|
|
|
105
143
|
_clientInstance.on("error", (err) => {
|
|
106
144
|
logger.warn("E2EE", "[native-media] " + (err && err.message ? err.message : String(err)));
|
|
107
145
|
});
|
|
146
|
+
_clientInstance.on("deviceDataChanged", () => _saveDeviceData(_clientInstance));
|
|
108
147
|
_clientInstance.on("disconnected", () => {
|
|
109
148
|
logger.warn("E2EE", "[native-media] native client disconnected, will reconnect on next send");
|
|
110
149
|
_connected = false;
|
|
@@ -119,6 +158,7 @@ async function getClient(appState) {
|
|
|
119
158
|
await _clientInstance.connect();
|
|
120
159
|
await _clientInstance.connectE2EE();
|
|
121
160
|
_connected = true;
|
|
161
|
+
_saveDeviceData(_clientInstance);
|
|
122
162
|
|
|
123
163
|
return _clientInstance;
|
|
124
164
|
})();
|
|
@@ -1285,8 +1285,8 @@ var ProtoWriter = class {
|
|
|
1285
1285
|
// src/e2ee/message/builders/client-payload.ts
|
|
1286
1286
|
function encodeClientPayload(opts) {
|
|
1287
1287
|
const appVersion = new ProtoWriter().varint(1, 301).varint(2, 0).varint(3, 2).build();
|
|
1288
|
-
const userAgent = new ProtoWriter().varint(1, 32).bytes(2, appVersion).string(3, "000").string(4, "000").string(5, "").string(6, "
|
|
1289
|
-
const UserAgentStr = "Mozilla/5.0 (
|
|
1288
|
+
const userAgent = new ProtoWriter().varint(1, 32).bytes(2, appVersion).string(3, "000").string(4, "000").string(5, "").string(6, "Windows").string(7, "Chrome").string(8, "").varint(10, 3).string(11, "en").string(12, "en").build();
|
|
1289
|
+
const UserAgentStr = process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
1290
1290
|
let w = new ProtoWriter().uint64_varint(1, opts.username).bool(3, false).bytes(5, userAgent).varint(12, 1).varint(13, 1).varint(18, opts.deviceId).varint(20, 1).bytes(21, opts.fbCatBase64 ? Buffer.from(opts.fbCatBase64) : Buffer.alloc(0)).bytes(22, opts.fbUserAgent ?? Buffer.from(UserAgentStr)).bool(33, true);
|
|
1291
1291
|
return w.build();
|
|
1292
1292
|
}
|
|
@@ -3439,9 +3439,9 @@ var FacebookE2EESocket = class extends import_node_events2.EventEmitter {
|
|
|
3439
3439
|
if (cookies) this.cookieHeader = cookies;
|
|
3440
3440
|
const wsUrl = new URL(this.url);
|
|
3441
3441
|
wsUrl.searchParams.set("cid", "client-" + Date.now());
|
|
3442
|
-
const UserAgentStr = "Mozilla/5.0 (
|
|
3442
|
+
const UserAgentStr = process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
3443
3443
|
const wsHeaders = {
|
|
3444
|
-
"Origin": "https://www.
|
|
3444
|
+
"Origin": "https://www.messenger.com",
|
|
3445
3445
|
"User-Agent": UserAgentStr
|
|
3446
3446
|
};
|
|
3447
3447
|
if (this.cookieHeader) {
|
|
@@ -6370,7 +6370,7 @@ var FBClient = class {
|
|
|
6370
6370
|
const threadService = new ThreadService(mediaService);
|
|
6371
6371
|
const e2eeService = new E2EEService();
|
|
6372
6372
|
const icdcService = new ICDCService(
|
|
6373
|
-
"Mozilla/5.0 (
|
|
6373
|
+
process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
|
|
6374
6374
|
);
|
|
6375
6375
|
this.controller = new ClientController(
|
|
6376
6376
|
authService,
|
|
@@ -18,7 +18,13 @@ const getTaskResponseData = require("./core/getTaskResponseData");
|
|
|
18
18
|
const createEmitAuth = require("./core/emitAuth");
|
|
19
19
|
const createMiddlewareSystem = require("./middleware");
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Auto-cycle is OFF by default now: a real browser keeps one MQTT socket
|
|
22
|
+
// open for hours. Cycling every exactly-3600.000s (full unsubscribe,
|
|
23
|
+
// /browser_close, two page loads, re-auth, resubscribe) on a clockwork
|
|
24
|
+
// schedule is itself a robot signature, and each cycle is a burst of
|
|
25
|
+
// session-touching requests. Reconnects still happen on actual socket
|
|
26
|
+
// close/error. Pass { cycleMs: <ms> } to listenMqtt to re-enable.
|
|
27
|
+
const CYCLE_MS_DEFAULT = 0;
|
|
22
28
|
const RECONNECT_DELAY_MS_DEFAULT = 2000;
|
|
23
29
|
const UNSUB_ALL_TIMEOUT_MS = 5000;
|
|
24
30
|
|
|
@@ -255,21 +261,31 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
255
261
|
|
|
256
262
|
function startSessionKeeper() {
|
|
257
263
|
if (ctx._sessionKeeperInterval) return;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
264
|
+
// Self-rescheduling timeout with ±15 min jitter around 90 min, instead of
|
|
265
|
+
// a clockwork-exact 90:00.000 interval — perfectly periodic traffic is
|
|
266
|
+
// trivial to fingerprint.
|
|
267
|
+
const BASE_MS = 90 * 60 * 1000;
|
|
268
|
+
const JITTER_MS = 15 * 60 * 1000;
|
|
269
|
+
const scheduleNext = function () {
|
|
270
|
+
const delay = BASE_MS - JITTER_MS + Math.floor(Math.random() * 2 * JITTER_MS);
|
|
271
|
+
ctx._sessionKeeperInterval = setTimeout(function () {
|
|
272
|
+
if (!ctx.loggedIn) { scheduleNext(); return; }
|
|
273
|
+
logger("sessionKeeper: refreshing cookies + fb_dtsg", "info");
|
|
274
|
+
defaultFuncs.get("https://www.messenger.com/", ctx.jar, null, ctx)
|
|
275
|
+
.then(function () {
|
|
276
|
+
if (typeof api.refreshFb_dtsg === "function") return api.refreshFb_dtsg();
|
|
277
|
+
})
|
|
278
|
+
.then(function () {
|
|
279
|
+
logger("sessionKeeper: refresh done", "info");
|
|
280
|
+
})
|
|
281
|
+
.catch(function (err) {
|
|
282
|
+
logger(`sessionKeeper: refresh failed - ${err && err.message ? err.message : String(err)}`, "warn");
|
|
283
|
+
})
|
|
284
|
+
.finally(scheduleNext);
|
|
285
|
+
}, delay);
|
|
286
|
+
if (ctx._sessionKeeperInterval.unref) ctx._sessionKeeperInterval.unref();
|
|
287
|
+
};
|
|
288
|
+
scheduleNext();
|
|
273
289
|
}
|
|
274
290
|
|
|
275
291
|
function startHeartbeat() {
|
|
@@ -277,9 +293,12 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
277
293
|
ctx.lastMqttActivity = Date.now();
|
|
278
294
|
ctx._heartbeatInterval = setInterval(function () {
|
|
279
295
|
if (!ctx.loggedIn) return;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
296
|
+
// Liveness is the socket's own state + mqtt.js keepalive, not "did a
|
|
297
|
+
// chat message arrive". The old check force-cycled a perfectly healthy
|
|
298
|
+
// connection every 45 quiet minutes (e.g. overnight), producing
|
|
299
|
+
// re-auth bursts exactly when a real user would be asleep.
|
|
300
|
+
if (!isConnected() && !ctx._ending && !ctx._cycling) {
|
|
301
|
+
logger("heartbeat: MQTT socket not connected, forcing reconnect", "warn");
|
|
283
302
|
ctx.lastMqttActivity = Date.now();
|
|
284
303
|
forceCycle();
|
|
285
304
|
}
|
package/src/utils/headers.js
CHANGED
|
@@ -37,6 +37,46 @@ function sanitizeHeaderName(name) {
|
|
|
37
37
|
return name.replace(/[^\x21-\x7E]/g, "").trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Derive the Sec-CH-UA client-hint set from the configured User-Agent instead
|
|
41
|
+
// of hardcoding it. Previously the hints always claimed "Windows desktop
|
|
42
|
+
// Chrome 139" even when the UA said "Android Chrome 101" — every single
|
|
43
|
+
// request then contradicted itself, which is a trivial server-side check.
|
|
44
|
+
function clientHintsFromUA(ua) {
|
|
45
|
+
const s = String(ua || "");
|
|
46
|
+
const chromeMatch = s.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
|
|
47
|
+
const major = chromeMatch ? chromeMatch[1] : "139";
|
|
48
|
+
const full = chromeMatch ? chromeMatch[0].split("/")[1] : `${major}.0.0.0`;
|
|
49
|
+
const isMobile = /Mobile|Android|iPhone|iPad/i.test(s);
|
|
50
|
+
let platform = "Windows";
|
|
51
|
+
let platformVersion = "\"15.0.0\"";
|
|
52
|
+
if (/Android\s*(\d+)/i.test(s)) {
|
|
53
|
+
platform = "Android";
|
|
54
|
+
platformVersion = `"${s.match(/Android\s*(\d+(?:\.\d+)?)/i)[1]}.0"`;
|
|
55
|
+
} else if (/iPhone|iPad/i.test(s)) {
|
|
56
|
+
platform = "iOS";
|
|
57
|
+
platformVersion = "\"17.0.0\"";
|
|
58
|
+
} else if (/Mac OS X/i.test(s)) {
|
|
59
|
+
platform = "macOS";
|
|
60
|
+
platformVersion = "\"14.0.0\"";
|
|
61
|
+
} else if (/Linux/i.test(s) && !/Android/i.test(s)) {
|
|
62
|
+
platform = "Linux";
|
|
63
|
+
platformVersion = "\"\"";
|
|
64
|
+
}
|
|
65
|
+
const hints = {
|
|
66
|
+
"sec-ch-ua": `"Chromium";v="${major}", "Not;A=Brand";v="24", "Google Chrome";v="${major}"`,
|
|
67
|
+
"sec-ch-ua-mobile": isMobile ? "?1" : "?0",
|
|
68
|
+
"sec-ch-ua-platform": `"${platform}"`,
|
|
69
|
+
"sec-ch-ua-full-version-list": `"Chromium";v="${full}", "Not;A=Brand";v="24.0.0.0", "Google Chrome";v="${full}"`,
|
|
70
|
+
"sec-ch-ua-platform-version": platformVersion
|
|
71
|
+
};
|
|
72
|
+
// Arch/bitness hints are only sent by desktop Chrome builds.
|
|
73
|
+
if (!isMobile) {
|
|
74
|
+
hints["sec-ch-ua-arch"] = "\"x86\"";
|
|
75
|
+
hints["sec-ch-ua-bitness"] = "\"64\"";
|
|
76
|
+
}
|
|
77
|
+
return hints;
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
function getHeaders(url, options, ctx, customHeader) {
|
|
41
81
|
const u = new URL(url);
|
|
42
82
|
const ua = options?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
@@ -44,11 +84,12 @@ function getHeaders(url, options, ctx, customHeader) {
|
|
|
44
84
|
const origin = referer.replace(/\/+$/, "");
|
|
45
85
|
const contentType = options?.contentType || "application/x-www-form-urlencoded";
|
|
46
86
|
const acceptLang = options?.acceptLanguage || "en-US,en;q=0.9,vi;q=0.8";
|
|
47
|
-
|
|
87
|
+
// NOTE: keep the && chain parenthesized — `||` binds looser than `&&`.
|
|
88
|
+
const isNavigation = (u.pathname === "/" &&
|
|
48
89
|
!url.includes("ajax") &&
|
|
49
90
|
!url.includes("graphql") &&
|
|
50
|
-
!
|
|
51
|
-
!url.includes("chat") ||
|
|
91
|
+
!u.pathname.includes("api") &&
|
|
92
|
+
!url.includes("chat")) ||
|
|
52
93
|
url.includes("checkpoint");
|
|
53
94
|
const acceptHeader = isNavigation
|
|
54
95
|
? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
|
|
@@ -65,13 +106,7 @@ function getHeaders(url, options, ctx, customHeader) {
|
|
|
65
106
|
Connection: "keep-alive",
|
|
66
107
|
DNT: "1",
|
|
67
108
|
"Upgrade-Insecure-Requests": "1",
|
|
68
|
-
|
|
69
|
-
"sec-ch-ua-mobile": "?0",
|
|
70
|
-
"sec-ch-ua-platform": "\"Windows\"",
|
|
71
|
-
"sec-ch-ua-arch": "\"x86\"",
|
|
72
|
-
"sec-ch-ua-bitness": "\"64\"",
|
|
73
|
-
"sec-ch-ua-full-version-list": "\"Chromium\";v=\"139.0.0.0\", \"Not;A=Brand\";v=\"24.0.0.0\", \"Google Chrome\";v=\"139.0.0.0\"",
|
|
74
|
-
"sec-ch-ua-platform-version": "\"15.0.0\"",
|
|
109
|
+
...clientHintsFromUA(ua),
|
|
75
110
|
"Sec-Fetch-Site": isNavigation ? "none" : "same-origin",
|
|
76
111
|
"Sec-Fetch-Mode": isNavigation ? "navigate" : "cors",
|
|
77
112
|
"Sec-Fetch-Dest": isNavigation ? "document" : "empty",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Multi-pattern fb_dtsg / jazoest extractor.
|
|
4
|
+
// Facebook serves the token in different shapes depending on surface, A/B
|
|
5
|
+
// bucket and login state (DTSGInitialData / DTSGInitData blobs, escaped JSON
|
|
6
|
+
// inside <script>, plain hidden inputs, async_get_token, ...). Matching only
|
|
7
|
+
// one shape is why refreshes intermittently fail with "Could not find
|
|
8
|
+
// fb_dtsg in HTML". Pattern order is first-match-wins.
|
|
9
|
+
|
|
10
|
+
const DTSG_PATTERNS = [
|
|
11
|
+
/\["DTSGInitialData",\[\],\{"token":"([^"]+)"\}\]/,
|
|
12
|
+
/\["DTSGInitData",\[\],\{"token":"([^"]+)"/,
|
|
13
|
+
/"DTSGInitData",\[\],\{"token":"([^"]+)"/,
|
|
14
|
+
/"dtsg":\{"token":"([^"]+)"/,
|
|
15
|
+
/\{\\"token\\":\\"([^\\]+)\\"/,
|
|
16
|
+
/,\{"token":"([^"]+)"\},\d+\]/,
|
|
17
|
+
/"async_get_token":"([^"]+)"/,
|
|
18
|
+
/name="fb_dtsg"\s+value="([^"]+)"/,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const JAZOEST_PATTERNS = [
|
|
22
|
+
/name="jazoest"\s+value="([^"]+)"/,
|
|
23
|
+
/jazoest=(\d+)/,
|
|
24
|
+
/"jazoest":"?(\d+)"?/,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function firstMatch(html, patterns) {
|
|
28
|
+
for (const pattern of patterns) {
|
|
29
|
+
const m = html.match(pattern);
|
|
30
|
+
if (m && m[1]) return m[1];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractDtsg(html) {
|
|
36
|
+
if (!html) return { fb_dtsg: null, jazoest: null };
|
|
37
|
+
const raw = String(html);
|
|
38
|
+
return {
|
|
39
|
+
fb_dtsg: firstMatch(raw, DTSG_PATTERNS),
|
|
40
|
+
jazoest: firstMatch(raw, JAZOEST_PATTERNS),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { extractDtsg, DTSG_PATTERNS, JAZOEST_PATTERNS };
|