@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 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. If your deployment platform isn't Linux/Windows x64 (e.g. macOS, ARM), the native engine will fail to load and E2EE media/reactions/unsend automatically fall back to the built-in JS engine (text messaging is unaffected either way).
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 is 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.
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
- autoLogin: true,
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: "" },
@@ -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.5",
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 = getFrom(data, '["DTSGInitData",[],{"token":"', '","');
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
- const chatOn = ctx.globalOptions.online;
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
- "Accept-Language": "vi,en;q=0.9",
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
- if (config.autoLogin === false || !email || !password) {
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
- return { ...result, cookies };
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|blocked the login|401|403/i.test(msg);
291
+ const isAuthError = /Not logged in|no sync_sequence_id found|401|403/i.test(msg);
272
292
 
273
- // For auth errors, try retry + auto-login
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
- // Retry logic with increasing delay
276
- if (retryCount < MAX_RETRIES) {
277
- const delay = RETRY_DELAY * (retryCount + 1); // Increasing delay: 2s, 4s, 6s
278
- logger(`getSeqID: retry ${retryCount + 1}/${MAX_RETRIES} after ${delay}ms... (error: ${msg})`, "warn");
279
- await new Promise(resolve => setTimeout(resolve, delay));
280
-
281
- // Before retrying, try to refresh the session if this is the first retry
282
- if (retryCount === 0 && ctx.loggedIn) {
283
- try {
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
- // All retries failed, try auto-login
297
- logger("getSeqID: all retries failed, attempting auto re-login...", "warn");
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
- const loginResult = await tryAutoLogin(logger, config, ctx, defaultFuncs);
300
-
301
- if (loginResult) {
302
- // Wait longer after auto-login to ensure session is ready
303
- logger("getSeqID: retrying with new session...", "info");
304
- await new Promise(resolve => setTimeout(resolve, 3000)); // Increased delay to 3s
305
- return getSeqID(defaultFuncs, api, ctx, globalCallback, form, 0);
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
- // Determine the auth error type
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
- logger(`getSeqID error: ${msg}`, "error");
318
- return emitAuth(ctx, api, globalCallback, "auth_error", msg);
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
- logger.warn("E2EE", "Noise WebSocket disconnected reconnecting in 5s...");
140
- setTimeout(async function () {
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
- // Retry again after another 10s if reconnect failed
148
- if (_self.connected) {
149
- setTimeout(async function () {
150
- if (!_self.connected) return;
151
- try {
152
- await _self.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
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
- }, 5000);
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
- logger.error("E2EE", "[native-group] failed to attach native message listener (non-fatal, DM unaffected): " +
430
- (err && err.message ? err.message : String(err)));
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
- return nativeMediaBridge.markRead(this.api.getAppState(), threadId, watermarkTs);
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
- return new Promise((resolve, reject) => {
103
- setTimeout(() => {
104
- try {
105
- const result = call(fn, payload);
106
- resolve(result);
107
- } catch (err) {
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) => call("MxNewClient", cfg), "newClient"),
116
- connect: /* @__PURE__ */ __name((handle) => call("MxConnect", { handle }), "connect"),
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) => call("MxDisconnect", { handle }), "disconnect"),
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, "Linux").string(7, "Chrome").string(8, "").varint(10, 3).string(11, "en").string(12, "en").build();
1289
- const UserAgentStr = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
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 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
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.facebook.com",
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 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
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
- const CYCLE_MS_DEFAULT = 60 * 60 * 1000;
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
- ctx._sessionKeeperInterval = setInterval(function () {
259
- if (!ctx.loggedIn) return;
260
- logger("sessionKeeper: refreshing cookies + fb_dtsg", "info");
261
- defaultFuncs.get("https://www.messenger.com/", ctx.jar, null, ctx)
262
- .then(function () {
263
- if (typeof api.refreshFb_dtsg === "function") return api.refreshFb_dtsg();
264
- })
265
- .then(function () {
266
- logger("sessionKeeper: refresh done", "info");
267
- })
268
- .catch(function (err) {
269
- logger(`sessionKeeper: refresh failed - ${err && err.message ? err.message : String(err)}`, "warn");
270
- });
271
- }, 90 * 60 * 1000);
272
- if (ctx._sessionKeeperInterval.unref) ctx._sessionKeeperInterval.unref();
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
- const idleTime = Date.now() - (ctx.lastMqttActivity || Date.now());
281
- if (idleTime > 45 * 60 * 1000) {
282
- logger("heartbeat: no MQTT activity for 45 minutes, forcing reconnect", "warn");
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
  }
@@ -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
- const isNavigation = u.pathname === "/" &&
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
- !url.includes("api") &&
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
- "sec-ch-ua": "\"Chromium\";v=\"139\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"139\"",
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 };