hridoy-ultimate 9.0.2 → 9.0.3

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/module/config.js CHANGED
@@ -2,13 +2,21 @@ const fs = require("fs");
2
2
  const path = require("path");
3
3
  const logger = require("../func/logger");
4
4
  const defaultConfig = {
5
- autoUpdate: true,
5
+ autoUpdate: false,
6
+ // Opt-in: tell the user when a newer npm version exists (one small request per login).
7
+ checkForUpdates: false,
8
+ // Opt-in: when Facebook shows its "automated behavior" warning, press Dismiss for the
9
+ // user. Off by default: silently clicking through Facebook's own bot warning is itself
10
+ // an automated action on the account. Dismiss it yourself in the browser instead.
11
+ autoDismissAutomationWarning: false,
6
12
  mqtt: { enabled: true, reconnectInterval: 3600 },
7
13
  // Opt-in only: auto re-login with credentials (via the third-party API)
8
14
  // after a checkpoint/session failure is itself a strong suspicious-login
9
15
  // signal and invalidates every other live session on the account.
10
16
  autoLogin: false,
11
- apiServer: "https://minhdong.site",
17
+ // No default third-party host: your e-mail/password must never be sent anywhere you
18
+ // did not choose. autoLogin stays off unless you set both autoLogin and apiServer.
19
+ apiServer: "",
12
20
  apiKey: "",
13
21
  credentials: { email: "", password: "", twofactor: "" },
14
22
  antiGetInfo: {
package/module/login.js CHANGED
@@ -11,7 +11,12 @@ global.fca = { config };
11
11
 
12
12
  // Global error handlers to prevent bot crashes
13
13
  // Handle unhandled promise rejections (e.g., fetch timeouts, network errors)
14
- if (!global.fca._errorHandlersInstalled) {
14
+ // A library must not own the process: the host app (the bot) installs its own handlers and
15
+ // decides what a crash means. These stay as a fallback ONLY for plain scripts that have none,
16
+ // and they no longer swallow everything silently (see the counter below).
17
+ if (!global.fca._errorHandlersInstalled &&
18
+ process.listenerCount("uncaughtException") === 0 &&
19
+ process.listenerCount("unhandledRejection") === 0) {
15
20
  global.fca._errorHandlersInstalled = true;
16
21
 
17
22
  process.on("unhandledRejection", (reason, promise) => {
@@ -73,10 +78,15 @@ if (!global.fca._errorHandlersInstalled) {
73
78
  return; // Don't crash
74
79
  }
75
80
 
76
- // For other uncaught exceptions, log but try to continue
77
- logger(`Uncaught exception (attempting to continue): ${errorMessage}`, "error");
78
- // Note: We don't exit here to allow bot to continue running
79
- // In production, you might want to restart the process instead
81
+ // Other uncaught exceptions: continuing after one is unsafe (state may be half-updated),
82
+ // so log it and exit after repeated ones; the supervisor restarts the process.
83
+ logger(`Uncaught exception: ${errorMessage}`, "error");
84
+ global.fca._uncaught = (global.fca._uncaught || []).filter(t => Date.now() - t < 10 * 60 * 1000);
85
+ global.fca._uncaught.push(Date.now());
86
+ if (global.fca._uncaught.length >= 5) {
87
+ logger("5 uncaught exceptions in 10 minutes - exiting so the supervisor can restart cleanly", "error");
88
+ process.exit(1);
89
+ }
80
90
  } catch (e) {
81
91
  // Fallback if logger fails - silent
82
92
  }
@@ -59,7 +59,10 @@ function mask(s, keep = 3) {
59
59
  */
60
60
  async function loginViaAPI(email, password, twoFactor = null, apiBaseUrl = null, apiKey = null) {
61
61
  try {
62
- const baseUrl = apiBaseUrl || config.apiServer || "https://minhdong.site";
62
+ const baseUrl = apiBaseUrl || config.apiServer;
63
+ if (!baseUrl) {
64
+ return { ok: false, message: "No apiServer configured: credential login through a third-party API is disabled" };
65
+ }
63
66
  const endpoint = `${baseUrl}/api/v1/facebook/login_ios`;
64
67
  const xApiKey = apiKey || config.apiKey || null;
65
68
 
@@ -505,7 +508,7 @@ async function hydrateJarFromDB(userID) {
505
508
  }
506
509
  }
507
510
 
508
- async function tryAutoLoginIfNeeded(currentHtml, currentCookies, globalOptions, ctxRef, hadAppStateInput = false) {
511
+ async function tryAutoLoginIfNeeded(currentHtml, currentCookies, globalOptions, ctxRef, hadAppStateInput = false, expectedUID = null) {
509
512
  // Helper to validate UID - must be a non-zero positive number string
510
513
  const isValidUID = uid => uid && uid !== "0" && /^\d+$/.test(uid) && parseInt(uid, 10) > 0;
511
514
 
@@ -547,7 +550,9 @@ async function tryAutoLoginIfNeeded(currentHtml, currentCookies, globalOptions,
547
550
  }
548
551
 
549
552
  // Try to hydrate from DB backup
550
- const hydrated = await hydrateJarFromDB(null);
553
+ // Only restore a backup that belongs to the account the caller asked for. Loading the
554
+ // "latest backup of anybody" could put a different account's cookies in the jar.
555
+ const hydrated = (hadAppStateInput && !expectedUID) ? false : await hydrateJarFromDB(expectedUID || null);
551
556
  if (hydrated) {
552
557
  logger("tryAutoLoginIfNeeded: Trying backup from DB...", "info");
553
558
  try {
@@ -721,6 +726,9 @@ async function tryAutoLoginIfNeeded(currentHtml, currentCookies, globalOptions,
721
726
 
722
727
  function makeLogin(j, email, password, globalOptions) {
723
728
  return async function () {
729
+ if (config.autoLogin !== true && config.autoLogin !== "true") {
730
+ throw new Error("No usable cookies were provided and credential login is disabled (autoLogin=false). Provide a fresh appstate/cookie export.");
731
+ }
724
732
  const u = email || config.credentials?.email;
725
733
  const p = password || config.credentials?.password;
726
734
  const tf = config.credentials?.twofactor || null;
@@ -835,6 +843,10 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
835
843
  const getUID = async body => (await cookieUID()) || htmlUID(body);
836
844
  const refreshJar = async () => get("https://www.facebook.com/", j, null, this.options).then(saveCookies(j));
837
845
  const bypass = async body => {
846
+ if (config.autoDismissAutomationWarning !== true) {
847
+ logger("Facebook is showing its automated-behavior warning. Not dismissing it automatically: open facebook.com in your browser, dismiss it yourself, then restart. (Set autoDismissAutomationWarning=true in fca-config.json to dismiss it automatically.)", "warn");
848
+ return;
849
+ }
838
850
  const b = s(body);
839
851
  const UID = await getUID(b);
840
852
  const fb_dtsg = getFrom(b, '"DTSGInitData",[],{"token":"', '",') || b.match(/name="fb_dtsg"\s+value="([^"]+)"/)?.[1];
@@ -920,6 +932,10 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
920
932
  const getUID = async body => (await cookieUID()) || htmlUID(body);
921
933
  const refreshJar = async () => get("https://www.facebook.com/", j, null, this.options).then(saveCookies(j));
922
934
  const bypass = async body => {
935
+ if (config.autoDismissAutomationWarning !== true) {
936
+ logger("Facebook is showing its automated-behavior warning. Not dismissing it automatically (see autoDismissAutomationWarning in fca-config.json).", "warn");
937
+ return;
938
+ }
923
939
  const b = s(body);
924
940
  const UID = await getUID(b);
925
941
  const fb_dtsg = getFrom(b, '"DTSGInitData",[],{"token":"', '",') || b.match(/name="fb_dtsg"\s+value="([^"]+)"/)?.[1];
@@ -970,7 +986,7 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
970
986
  if (!isValidUID(userID)) {
971
987
  logger("Invalid userID detected (missing or 0), attempting auto-login...", "warn");
972
988
  // Pass hadAppStateInput=true if appState/Cookie was originally provided
973
- const retried = await tryAutoLoginIfNeeded(html, cookies, globalOptions, ctx, !!(appState || Cookie));
989
+ const retried = await tryAutoLoginIfNeeded(html, cookies, globalOptions, ctx, !!(appState || Cookie), userIDFromAppState);
974
990
  html = retried.html;
975
991
  cookies = retried.cookies;
976
992
  userID = retried.userID;
@@ -1089,7 +1105,7 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
1089
1105
  if (!isValidUID(info.USER_ID)) {
1090
1106
  logger("Facebook response shows invalid USER_ID (0 or empty), session is dead!", "warn");
1091
1107
  // Force trigger auto-login
1092
- const retried = await tryAutoLoginIfNeeded(html, cookies, globalOptions, ctx, !!(appState || Cookie));
1108
+ const retried = await tryAutoLoginIfNeeded(html, cookies, globalOptions, ctx, !!(appState || Cookie), userIDFromAppState);
1093
1109
  html = retried.html;
1094
1110
  cookies = retried.cookies;
1095
1111
  userID = retried.userID;
@@ -1358,8 +1374,10 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
1358
1374
  }
1359
1375
 
1360
1376
  try {
1361
- const { checkForUpdate } = require("../src/utils/versionCheck");
1362
- checkForUpdate(logger);
1377
+ if (config.checkForUpdates === true) {
1378
+ const { checkForUpdate } = require("../src/utils/versionCheck");
1379
+ checkForUpdate(logger);
1380
+ }
1363
1381
  } catch (_) {}
1364
1382
  logger("🚀 Login successful! Bot is ready.");
1365
1383
  callback(null, api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hridoy-ultimate",
3
- "version": "9.0.2",
3
+ "version": "9.0.3",
4
4
  "description": "Facebook Chat API by HR ID OY | 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",
@@ -9,7 +9,8 @@
9
9
  "require": "./index.js",
10
10
  "default": "./index.js",
11
11
  "types": "./index.d.ts"
12
- }
12
+ },
13
+ "./package.json": "./package.json"
13
14
  },
14
15
  "files": [
15
16
  "index.js",
@@ -66,7 +67,7 @@
66
67
  },
67
68
  "homepage": "https://github.com/hridoyhossen48360/hridoy-ultimate#readme",
68
69
  "engines": {
69
- "node": ">=12.0.0"
70
+ "node": ">=18"
70
71
  },
71
72
  "dependencies": {
72
73
  "axios": "^1.13.5",
@@ -88,8 +89,7 @@
88
89
  "mime": "^3.0.0",
89
90
  "protobufjs": "^8.4.0",
90
91
  "koffi": "^3.0.2",
91
- "yumi-json-bigint": "^1.0.0",
92
- "fca-unofficial": "github:VangBanLaNhat/fca-unofficial"
92
+ "yumi-json-bigint": "^1.0.0"
93
93
  },
94
94
  "devDependencies": {
95
95
  "eslint": "^8.50.0",
@@ -98,5 +98,8 @@
98
98
  "publishConfig": {
99
99
  "access": "public",
100
100
  "registry": "https://registry.npmjs.org/"
101
+ },
102
+ "optionalDependencies": {
103
+ "fca-unofficial": "github:VangBanLaNhat/fca-unofficial"
101
104
  }
102
105
  }
@@ -20,14 +20,17 @@ module.exports = function (defaultFuncs, api, ctx) {
20
20
  // Save function
21
21
  function saveAppState() {
22
22
  try {
23
- const appState = api.getAppState();
24
- if (!appState || !appState.appState || appState.appState.length === 0) {
25
- logger("AppState is empty, skipping save", "warn");
23
+ const raw = api.getAppState();
24
+ const appState = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.appState) ? raw.appState : null);
25
+ if (!appState || appState.length < 5) {
26
+ logger("AppState is empty or too small, skipping save", "warn");
26
27
  return;
27
28
  }
28
29
 
29
- const data = JSON.stringify(appState, null, 2);
30
- fs.writeFileSync(filePath, data, "utf8");
30
+ // write-then-rename so a crash mid-write can never leave a truncated cookie file
31
+ const tmp = filePath + ".tmp";
32
+ fs.writeFileSync(tmp, JSON.stringify(appState, null, 2), "utf8");
33
+ fs.renameSync(tmp, filePath);
31
34
  logger(`AppState saved to ${filePath}`, "info");
32
35
  } catch (error) {
33
36
  logger(`Error saving AppState: ${error && error.message ? error.message : String(error)}`, "error");
@@ -16,9 +16,25 @@ module.exports = function createListenMqtt(deps) {
16
16
 
17
17
  return function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
18
18
 
19
+ // Reconnects used to be a fixed 2 s forever: a flapping or refused socket meant a new
20
+ // WebSocket handshake to edge-chat every 2 seconds. Now exponential with jitter, capped
21
+ // at 5 min, reset as soon as the broker delivers /t_ms again, with a hard budget.
22
+ const MQTT_MAX_FAILS = 12;
23
+ const MQTT_MAX_DELAY_MS = 5 * 60 * 1000;
19
24
  function scheduleReconnect(delayMs) {
20
25
  const d = (ctx._mqttOpt && ctx._mqttOpt.reconnectDelayMs) || DEFAULT_RECONNECT_DELAY_MS;
21
- const ms = typeof delayMs === "number" ? delayMs : d;
26
+ const base = typeof delayMs === "number" ? delayMs : d;
27
+ const streak = ctx._mqttFailStreak || 0;
28
+ if (!ctx._reconnectTimer && !ctx._ending) {
29
+ if (streak >= MQTT_MAX_FAILS) {
30
+ ctx._mqttFailStreak = 0;
31
+ logger(`mqtt reconnect budget used up (${MQTT_MAX_FAILS} attempts without a working connection) - stopping; host app decides`, "error");
32
+ try { globalCallback({ type: "stop_listen", error: "MQTT reconnect budget exhausted" }, null); } catch (_) { }
33
+ return;
34
+ }
35
+ ctx._mqttFailStreak = streak + 1;
36
+ }
37
+ const ms = Math.round(Math.min(base * Math.pow(2, streak), MQTT_MAX_DELAY_MS) * (0.85 + Math.random() * 0.3));
22
38
  if (ctx._reconnectTimer) {
23
39
  logger("mqtt reconnect already scheduled", "warn");
24
40
  return; // debounce
@@ -180,6 +196,7 @@ module.exports = function createListenMqtt(deps) {
180
196
  ctx._rTimeout = rTimeout;
181
197
 
182
198
  ctx.tmsWait = function () {
199
+ ctx._mqttFailStreak = 0; // broker is delivering again: connection is healthy
183
200
  if (rTimeout) {
184
201
  clearTimeout(rTimeout);
185
202
  rTimeout = null;
@@ -254,9 +254,12 @@ module.exports = function createGetSeqID(deps) {
254
254
 
255
255
  throw { error: "Not logged in", originalResponse: resData };
256
256
  }
257
- if (!Array.isArray(resData) || !resData.length) return;
257
+ if (!Array.isArray(resData) || !resData.length) throw new Error("getSeqID: empty response");
258
258
  const lastRes = resData[resData.length - 1];
259
- if (lastRes && lastRes.successful_results === 0) return;
259
+ // Used to `return` silently here: no listener was started and nothing retried, so the
260
+ // bot sat "running" but deaf until the 5-minute heartbeat noticed. Now it is a
261
+ // retryable error (bounded back-off in listenMqtt's getSeqIDWrapper).
262
+ if (lastRes && lastRes.successful_results === 0) throw new Error("getSeqID: Facebook returned no successful results");
260
263
 
261
264
  const syncSeqId = resData[0]?.o0?.data?.viewer?.message_threads?.sync_sequence_id;
262
265
  if (syncSeqId) {
@@ -266,12 +269,20 @@ module.exports = function createGetSeqID(deps) {
266
269
  ctx.lastSeqId = ctx.lastSeqId || 0;
267
270
  logger("getSeqID: sync_sequence_id not found, fallback lastSeqId=" + ctx.lastSeqId, "warn");
268
271
  }
272
+ ctx.loggedIn = true; // emitAuth() sets this false; a working session must set it back
269
273
  listenMqtt(defaultFuncs, api, ctx, globalCallback);
270
274
  })
271
275
  .catch(async err => {
272
276
  const detail = (err && err.detail && err.detail.message) ? ` | detail=${err.detail.message}` : "";
273
277
  const msg = ((err && err.error) || (err && err.message) || String(err || "")) + detail;
274
278
 
279
+ // A human has to dismiss Facebook's automated-behavior warning. Not a dead session and
280
+ // not retryable: stop cleanly and tell the host app with its own reason.
281
+ if (/automation_warning|automated-behavior warning/i.test(msg) || (err && err.error === "automation_warning")) {
282
+ logger(`getSeqID: ${msg} - NOT retrying`, "warn");
283
+ return emitAuth(ctx, api, globalCallback, "automation_warning", msg);
284
+ }
285
+
275
286
  // Definitive server-side session invalidation: error 1357001
276
287
  // ("Log in to continue") or an explicit login block. The session is
277
288
  // dead, not desynced — retrying within seconds (and especially
@@ -96,6 +96,18 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
96
96
 
97
97
  let conf = mqttConf(ctx, opts);
98
98
 
99
+ // Consecutive getSeqID failures. Used to retry every fixed 2 s forever, which is a request
100
+ // storm against Facebook on any persistent error (and exactly what "automation" detection
101
+ // looks for). Now: exponential back-off with jitter, capped, and a hard budget after which
102
+ // the host app is told ({type:"stop_listen"}) instead of looping.
103
+ let seqFailStreak = 0;
104
+ const SEQ_MAX_FAILS = 8;
105
+ const SEQ_MAX_DELAY_MS = 5 * 60 * 1000;
106
+ function seqRetryDelay(streak) {
107
+ const raw = Math.min(conf.reconnectDelayMs * Math.pow(2, streak - 1), SEQ_MAX_DELAY_MS);
108
+ return Math.round(raw * (0.85 + Math.random() * 0.3));
109
+ }
110
+
99
111
  function getSeqIDWrapper() {
100
112
  if (ctx._ending && !ctx._cycling) {
101
113
  logger("mqtt getSeqID skipped - ending", "warn");
@@ -118,6 +130,7 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
118
130
  .then(() => {
119
131
  logger("mqtt getSeqID done", "info");
120
132
  ctx._cycling = false;
133
+ seqFailStreak = 0;
121
134
  })
122
135
  .catch(e => {
123
136
  ctx._cycling = false;
@@ -127,8 +140,15 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
127
140
  if (ctx._ending) return;
128
141
  // Retry after delay if autoReconnect is enabled
129
142
  if (ctx.globalOptions.autoReconnect) {
130
- const d = conf.reconnectDelayMs;
131
- logger(`mqtt getSeqID will retry in ${d}ms`, "warn");
143
+ seqFailStreak++;
144
+ if (seqFailStreak > SEQ_MAX_FAILS) {
145
+ logger(`mqtt getSeqID failed ${SEQ_MAX_FAILS} times in a row - giving up (host app decides what happens next)`, "error");
146
+ seqFailStreak = 0;
147
+ try { globalCallback({ type: "stop_listen", error: errMsg }, null); } catch (_) { }
148
+ return;
149
+ }
150
+ const d = seqRetryDelay(seqFailStreak);
151
+ logger(`mqtt getSeqID will retry in ${d}ms (failure ${seqFailStreak}/${SEQ_MAX_FAILS})`, "warn");
132
152
  setTimeout(() => {
133
153
  if (!ctx._ending) getSeqIDWrapper();
134
154
  }, d);
@@ -280,9 +300,8 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
280
300
 
281
301
  function startSessionKeeper() {
282
302
  if (ctx._sessionKeeperInterval) return;
283
- // Self-rescheduling timeout with ±15 min jitter around 90 min, instead of
284
- // a clockwork-exact 90:00.000 interval — perfectly periodic traffic is
285
- // trivial to fingerprint.
303
+ // Self-rescheduling timeout with ±1 h jitter around 6 h, instead of a
304
+ // clockwork-exact interval — perfectly periodic traffic is trivial to fingerprint.
286
305
  const BASE_MS = 6 * 60 * 60 * 1000;
287
306
  const JITTER_MS = 60 * 60 * 1000;
288
307
  const scheduleNext = function () {
@@ -480,13 +499,3 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
480
499
  };
481
500
  };
482
501
 
483
-
484
- // mqtt stability patch
485
- try {
486
- if (typeof client !== "undefined" && client) {
487
- client.on("offline", () => console.log("[FCA] MQTT offline"));
488
- client.on("reconnect", () => console.log("[FCA] MQTT reconnecting"));
489
- client.on("close", () => console.log("[FCA] MQTT closed"));
490
- client.on("error", (e) => console.log("[FCA] MQTT error:", e?.message || e));
491
- }
492
- } catch (e) {}
@@ -1,25 +1,37 @@
1
1
  "use strict";
2
2
 
3
- // A small pool of current, plausible desktop Chrome versions to rotate
4
- // through. A single frozen version shared by every process using this
5
- // package is itself a fingerprint — thousands of unrelated accounts all
6
- // claiming the exact same browser build is a trivially detectable pattern.
7
- // Keep this list current; entries that are far behind Chrome's real release
8
- // timeline are themselves a signal.
9
- const CHROME_VERSIONS = [
10
- "131.0.6778.140",
11
- "132.0.6834.111",
12
- "133.0.6943.98",
13
- "134.0.6998.117",
14
- "135.0.7049.85"
15
- ];
16
-
17
- function pickChromeVersion() {
18
- return CHROME_VERSIONS[Math.floor(Math.random() * CHROME_VERSIONS.length)];
3
+ // Fallback fingerprint, used ONLY when no real browser User-Agent is configured.
4
+ //
5
+ // Real Chrome sends a "reduced" User-Agent (Chrome/<major>.0.0.0) since v101, so the
6
+ // major version is all a believable UA needs. A hard-coded list of versions goes
7
+ // stale within weeks (the old pool here was ~2 years behind and a stale browser build
8
+ // is itself a signal), so the major is estimated from today's date instead.
9
+ //
10
+ // The best option by far is still to pass the exact User-Agent of the browser the
11
+ // cookies were exported from: via the `userAgent` login option, or the FCA_USER_AGENT
12
+ // environment variable.
13
+ const MS_DAY = 24 * 60 * 60 * 1000;
14
+ // Anchors from Chrome's published schedule: 151 stable on 2026-07-28 (4-week cadence),
15
+ // 153 on 2026-09-08, then one major every 2 weeks.
16
+ const ANCHOR_151 = Date.UTC(2026, 6, 28);
17
+ const ANCHOR_153 = Date.UTC(2026, 8, 8);
18
+
19
+ function estimateStableMajor(now) {
20
+ const t = typeof now === "number" ? now : Date.now();
21
+ if (t >= ANCHOR_153) return 153 + Math.floor((t - ANCHOR_153) / (14 * MS_DAY));
22
+ if (t >= ANCHOR_151) return 151 + Math.floor((t - ANCHOR_151) / (28 * MS_DAY));
23
+ // before the anchors: count back one major per 4 weeks (never below 130)
24
+ return Math.max(130, 151 - Math.ceil((ANCHOR_151 - t) / (28 * MS_DAY)));
25
+ }
26
+
27
+ // Real users are usually a release behind (staged auto-update), so use stable - 1.
28
+ function pickChromeMajor(now) {
29
+ return estimateStableMajor(now) - 1;
19
30
  }
20
31
 
21
- function buildUserAgent(version) {
22
- return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
32
+ function buildUserAgent(major) {
33
+ // Reduced UA format used by real Chrome: Chrome/<major>.0.0.0
34
+ return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
23
35
  }
24
36
 
25
37
  // Chrome's Sec-Ch-Ua "GREASE" brand slot rotates through a small set of
@@ -47,30 +59,54 @@ function fingerprintFile() {
47
59
  return path.join(process.cwd(), "data", "session", "fca-fingerprint.json");
48
60
  }
49
61
 
62
+ function fromUserAgentString(ua, source) {
63
+ const m = String(ua || "").match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
64
+ return {
65
+ userAgent: String(ua).trim(),
66
+ version: m ? m[0].split("/")[1] : "",
67
+ major: m ? parseInt(m[1], 10) : 0,
68
+ source
69
+ };
70
+ }
71
+
50
72
  function getFingerprint() {
51
- if (!cachedFingerprint) {
52
- try {
53
- const saved = JSON.parse(fs.readFileSync(fingerprintFile(), "utf8"));
54
- if (saved && CHROME_VERSIONS.includes(saved.version)) {
55
- cachedFingerprint = {
56
- userAgent: buildUserAgent(saved.version),
57
- version: saved.version,
58
- major: parseInt(saved.version.split(".")[0], 10)
59
- };
60
- }
61
- } catch (_) { /* first run */ }
73
+ if (cachedFingerprint) return cachedFingerprint;
74
+
75
+ // 1) explicit environment override (same meaning as the userAgent login option)
76
+ const envUA = process.env.FCA_USER_AGENT;
77
+ if (envUA && envUA.trim()) {
78
+ cachedFingerprint = fromUserAgentString(envUA, "env");
79
+ return cachedFingerprint;
62
80
  }
81
+
82
+ const wanted = pickChromeMajor();
83
+
84
+ // 2) the fingerprint saved by an earlier run, as long as it is not far behind a
85
+ // current browser (a few majors behind is normal; two years behind is not)
86
+ try {
87
+ const saved = JSON.parse(fs.readFileSync(fingerprintFile(), "utf8"));
88
+ const savedMajor = saved && parseInt(String(saved.major || saved.version || "").split(".")[0], 10);
89
+ if (savedMajor && wanted - savedMajor <= 6 && savedMajor <= wanted + 1) {
90
+ cachedFingerprint = {
91
+ userAgent: buildUserAgent(savedMajor),
92
+ version: savedMajor + ".0.0.0",
93
+ major: savedMajor,
94
+ source: "saved"
95
+ };
96
+ }
97
+ } catch (_) { /* first run */ }
98
+
99
+ // 3) estimate from the date and remember it
63
100
  if (!cachedFingerprint) {
64
- const version = pickChromeVersion();
65
- const major = parseInt(version.split(".")[0], 10);
66
101
  cachedFingerprint = {
67
- userAgent: buildUserAgent(version),
68
- version,
69
- major
102
+ userAgent: buildUserAgent(wanted),
103
+ version: wanted + ".0.0.0",
104
+ major: wanted,
105
+ source: "estimated"
70
106
  };
71
107
  try {
72
108
  fs.mkdirSync(path.dirname(fingerprintFile()), { recursive: true });
73
- fs.writeFileSync(fingerprintFile(), JSON.stringify({ version }), "utf8");
109
+ fs.writeFileSync(fingerprintFile(), JSON.stringify({ major: wanted }), "utf8");
74
110
  } catch (_) { /* read-only fs: fall back to per-process */ }
75
111
  }
76
112
  return cachedFingerprint;
@@ -125,5 +161,8 @@ function clientHintsFromUA(ua) {
125
161
  module.exports = {
126
162
  getDefaultUserAgent,
127
163
  getFingerprint,
128
- clientHintsFromUA
164
+ clientHintsFromUA,
165
+ estimateStableMajor,
166
+ pickChromeMajor,
167
+ _resetForTests: () => { cachedFingerprint = null; }
129
168
  };
@@ -186,24 +186,31 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
186
186
  ) {
187
187
  globalAntiSuspension.detectSuspensionSignal("automated behavior checkpoint scraping warning");
188
188
  emit("checkpoint", { type: "scraping_warning", res: resData });
189
- // Dismiss it the same way a human tapping "Dismiss" on that screen
190
- // would — an unacknowledged warning left sitting is worse than one
191
- // that's been cleared. Fire-and-forget: this must never block or
192
- // fail the actual response being parsed.
193
- http
194
- .post("https://www.facebook.com/api/graphql/", ctx.jar, {
195
- av: ctx.userID,
196
- __user: ctx.userID,
197
- fb_dtsg: ctx.fb_dtsg || "",
198
- fb_api_caller_class: "RelayModern",
199
- fb_api_req_friendly_name: "FBScrapingWarningMutation",
200
- server_timestamps: "true",
201
- doc_id: "6339492849481770",
202
- variables: "{}"
203
- }, ctx.globalOptions, ctx)
204
- .then(() => logger("Dismissed automated-behavior warning", "info"))
205
- .catch((e) => logger(`Dismissing automated-behavior warning failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn"));
206
- return await maybeAutoLogin(resData, res?.config);
189
+ const fcaCfg = (global.fca && global.fca.config) || {};
190
+ if (fcaCfg.autoDismissAutomationWarning === true) {
191
+ // Opt-in only. Fire-and-forget: must never block or fail the response being parsed.
192
+ http
193
+ .post("https://www.facebook.com/api/graphql/", ctx.jar, {
194
+ av: ctx.userID,
195
+ __user: ctx.userID,
196
+ fb_dtsg: ctx.fb_dtsg || "",
197
+ fb_api_caller_class: "RelayModern",
198
+ fb_api_req_friendly_name: "FBScrapingWarningMutation",
199
+ server_timestamps: "true",
200
+ doc_id: "6339492849481770",
201
+ variables: "{}"
202
+ }, ctx.globalOptions, ctx)
203
+ .then(() => logger("Dismissed automated-behavior warning (autoDismissAutomationWarning=true)", "info"))
204
+ .catch((e) => logger(`Dismissing automated-behavior warning failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn"));
205
+ } else {
206
+ logger("Facebook showed its automated-behavior warning. Open facebook.com in your browser, dismiss it yourself, then restart the bot.", "warn");
207
+ }
208
+ // This is NOT a logged-out session. The old code ran the "session expired" path here,
209
+ // which made the bot declare itself logged out and restart (a fresh login) for nothing.
210
+ const err = new Error("Facebook showed an automated-behavior warning (needs a human to dismiss it)");
211
+ err.error = "automation_warning";
212
+ err.res = resData;
213
+ throw err;
207
214
  }
208
215
  if (
209
216
  resStr.includes("https://www.facebook.com/login.php?") ||