@lazyneoaz/metachat 1.0.6 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyneoaz/metachat",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "type": "commonjs",
5
5
  "types": "src/types/index.d.ts",
6
6
  "description": "Advanced Facebook Chat API client for building Messenger bots — real-time messaging, thread management, MQTT, and session stability.",
@@ -688,23 +688,15 @@ module.exports = (defaultFuncs, api, ctx, opts) => {
688
688
  timestamp: Date.now()
689
689
  }, null);
690
690
  }
691
+ // handleSessionExpiry owns the listenMqtt restart after re-login —
692
+ // it captures wasListening from the OLD ctx before calling loginHelper,
693
+ // so it is the only place that reliably knows whether listening was active.
694
+ // DO NOT restart listenMqtt here; doing so races with handleSessionExpiry
695
+ // and creates two simultaneous MQTT connections.
691
696
  try {
692
697
  if (globalAutoReLoginManager && globalAutoReLoginManager.isEnabled && globalAutoReLoginManager.isEnabled()) {
693
- globalAutoReLoginManager.handleSessionExpiry(api, 'https://www.facebook.com', "Session expired").then((ok) => {
694
- if (ok && ctx._listeningActive && typeof api.listenMqtt === 'function') {
695
- try {
696
- if (typeof api.stopListening === 'function') {
697
- try { api.stopListening(); } catch (_) {}
698
- }
699
- const cb = ctx._lastListenCallback || null;
700
- if (cb) {
701
- api.listenMqtt(cb);
702
- } else {
703
- api.listenMqtt();
704
- }
705
- } catch (_) {}
706
- }
707
- }).catch(() => {});
698
+ globalAutoReLoginManager.handleSessionExpiry(api, 'https://www.facebook.com', "Session expired")
699
+ .catch(() => {});
708
700
  }
709
701
  } catch (_) {}
710
702
  }
@@ -816,16 +808,39 @@ module.exports = (defaultFuncs, api, ctx, opts) => {
816
808
  } catch (err) {
817
809
  const detail = (err && err.detail && err.detail.message) ? ` | detail=${err.detail.message}` : "";
818
810
  const msg = ((err && err.error) || (err && err.message) || String(err || "")) + detail;
819
-
820
- if (/Not logged in/i.test(msg)) {
821
- utils.error("MQTT", "Auth error in getSeqID: Not logged in");
822
- return emitAuthError("not_logged_in", msg);
823
- }
824
- if (/blocked the login|checkpoint|security check|session.*expir|invalid.*session|authentication.*fail|auth.*fail|login.*block|account.*lock|verification.*requir/i.test(msg)) {
825
- utils.error("MQTT", "Auth error in getSeqID: Session/Login blocked");
826
- return emitAuthError("login_blocked", msg);
811
+
812
+ const isNotLoggedIn = /Not logged in/i.test(msg);
813
+ const isLoginBlocked = /blocked the login|checkpoint|security check|session.*expir|invalid.*session|authentication.*fail|auth.*fail|login.*block|account.*lock|verification.*requir/i.test(msg);
814
+
815
+ if (isNotLoggedIn || isLoginBlocked) {
816
+ const reason = isLoginBlocked ? "login_blocked" : "not_logged_in";
817
+ utils.error("MQTT", `Auth error in getSeqID: ${reason} — attempting auto re-login`);
818
+
819
+ // Mirror the close-handler re-login pattern: try handleSessionExpiry first.
820
+ // If it succeeds we schedule a reconnect; only fall back to emitAuthError
821
+ // (which kills listening) when re-login is unavailable or already in progress.
822
+ if (!ctx._mqttReauthing && globalAutoReLoginManager && globalAutoReLoginManager.isEnabled && globalAutoReLoginManager.isEnabled()) {
823
+ ctx._mqttReauthing = true;
824
+ globalAutoReLoginManager.handleSessionExpiry(api, 'https://www.facebook.com', msg)
825
+ .then((ok) => {
826
+ ctx._mqttReauthing = false;
827
+ if (ok && ctx.globalOptions.autoReconnect) {
828
+ ctx._reconnectAttempts = 0;
829
+ scheduleReconnect((ctx._mqttOpt && ctx._mqttOpt.reconnectDelayMs) || 2000);
830
+ } else if (!ok) {
831
+ emitAuthError(reason, msg);
832
+ }
833
+ })
834
+ .catch(() => {
835
+ ctx._mqttReauthing = false;
836
+ emitAuthError(reason, msg);
837
+ });
838
+ return;
839
+ }
840
+
841
+ return emitAuthError(reason, msg);
827
842
  }
828
-
843
+
829
844
  utils.error("MQTT", "getSeqID error:", msg);
830
845
  if (ctx.globalOptions.autoReconnect) {
831
846
  const baseDelay = (ctx._mqttOpt && ctx._mqttOpt.reconnectDelayMs) || 2000;
@@ -1020,9 +1035,14 @@ module.exports = (defaultFuncs, api, ctx, opts) => {
1020
1035
  'account_inactive', 'checkpoint_282', 'checkpoint_956', 'error'
1021
1036
  ];
1022
1037
  for (const event of LIFECYCLE_EVENTS) {
1023
- const listeners = prevEmitter.rawListeners ? prevEmitter.rawListeners(event) : [];
1024
- for (const listener of listeners) {
1025
- msgEmitter.on(event, listener);
1038
+ // Use listeners() NOT rawListeners() rawListeners() returns the
1039
+ // internal once-wrapper functions. When re-registered with .on()
1040
+ // those wrappers fire the handler once then remove themselves from
1041
+ // the OLD emitter, but stay on the NEW emitter forever, causing a
1042
+ // memory leak and never-again-firing once handlers.
1043
+ const fns = prevEmitter.listeners ? prevEmitter.listeners(event) : [];
1044
+ for (const fn of fns) {
1045
+ msgEmitter.on(event, fn);
1026
1046
  }
1027
1047
  }
1028
1048
  } catch (_) {}
@@ -60,7 +60,13 @@ module.exports = function (defaultFuncs, api, ctx) {
60
60
 
61
61
  if (!fb_dtsg) throw new Error("Could not find fb_dtsg in HTML. Session may have expired.");
62
62
 
63
- const updated = { fb_dtsg };
63
+ // Recalculate ttstamp whenever fb_dtsg changes — ttstamp = "2" followed
64
+ // by the concatenation of each character's char code (NOT their sum).
65
+ // Leaving ttstamp stale after a token refresh causes request signature
66
+ // mismatches that Facebook detects as bot-like behaviour.
67
+ const ttstamp = "2" + Array.from(fb_dtsg).map(c => c.charCodeAt(0)).join("");
68
+
69
+ const updated = { fb_dtsg, ttstamp };
64
70
  if (jazoest) updated.jazoest = jazoest;
65
71
 
66
72
  Object.assign(ctx, updated);
@@ -300,32 +300,44 @@ module.exports = (defaultFuncs, api, ctx) => {
300
300
  }
301
301
 
302
302
  try {
303
- let result;
304
303
  const mqttReady = ctx.mqttClient && ctx.mqttClient.connected;
305
304
  const isMultiRecipient = Array.isArray(threadID);
305
+ // Track which transport was used so the catch block can fall back to
306
+ // the OTHER transport — retrying the same one that just failed is useless.
307
+ const usedMqtt = mqttReady && !isMultiRecipient && api.sendMessageMqtt;
306
308
 
307
- if (mqttReady && !isMultiRecipient && api.sendMessageMqtt) {
309
+ let result;
310
+ if (usedMqtt) {
308
311
  result = await api.sendMessageMqtt(msg, threadID, replyToMessage);
309
312
  } else {
310
313
  result = await sendViaHttp(msg, threadID, replyToMessage, isGroup);
311
314
  }
312
315
  callback(null, result);
313
316
  } catch (sendErr) {
314
- const mqttReady = ctx.mqttClient && ctx.mqttClient.connected;
315
- if (mqttReady && !Array.isArray(threadID) && api.sendMessageMqtt) {
316
- try {
317
- const mqttRes = await api.sendMessageMqtt(msg, threadID, replyToMessage);
318
- callback(null, mqttRes);
319
- } catch (_mqttErr) {
320
- callback(sendErr);
321
- }
322
- } else {
317
+ // Fall back to the OTHER transport, not the one that just failed.
318
+ const mqttNowReady = ctx.mqttClient && ctx.mqttClient.connected;
319
+ const primaryWasMqtt = mqttNowReady && !Array.isArray(threadID) && api.sendMessageMqtt;
320
+
321
+ if (primaryWasMqtt) {
322
+ // MQTT was used (or is available) — fall back to HTTP
323
323
  try {
324
324
  const httpRes = await sendViaHttp(msg, threadID, replyToMessage, isGroup);
325
325
  callback(null, httpRes);
326
326
  } catch (_httpErr) {
327
327
  callback(sendErr);
328
328
  }
329
+ } else {
330
+ // HTTP was used — try MQTT if it has since become available
331
+ if (mqttNowReady && !Array.isArray(threadID) && api.sendMessageMqtt) {
332
+ try {
333
+ const mqttRes = await api.sendMessageMqtt(msg, threadID, replyToMessage);
334
+ callback(null, mqttRes);
335
+ } catch (_mqttErr) {
336
+ callback(sendErr);
337
+ }
338
+ } else {
339
+ callback(sendErr);
340
+ }
329
341
  }
330
342
  } finally {
331
343
  if (typingTimeout) clearTimeout(typingTimeout);
@@ -205,6 +205,14 @@ class AntiSuspension {
205
205
  detectSuspensionSignal(text) {
206
206
  if (!text || typeof text !== 'string') return false;
207
207
  const lower = text.toLowerCase();
208
+
209
+ // Session expiry is NOT a suspension event — never trip the circuit
210
+ // breaker for it. A false positive here blocks all sends AND re-login
211
+ // attempts for up to 45 minutes, turning a normal logout into a
212
+ // permanent lockout until the cooldown expires.
213
+ const isSessionExpiry = SESSION_EXPIRY_SIGNALS.some(signal => lower.includes(signal));
214
+ if (isSessionExpiry) return false;
215
+
208
216
  const found = SUSPENSION_SIGNALS.some(signal => lower.includes(signal));
209
217
  if (found) {
210
218
  this._onSuspensionSignalDetected();
@@ -240,11 +248,14 @@ class AntiSuspension {
240
248
  isCircuitBreakerTripped() {
241
249
  const cb = this.suspensionCircuitBreaker;
242
250
  if (!cb.tripped) return false;
251
+ // Use per-trip override duration if set, otherwise fall back to default.
252
+ const activeCooldown = cb.overrideCooldownMs || cb.cooldownMs;
243
253
  const elapsed = Date.now() - cb.trippedAt;
244
- if (elapsed >= cb.cooldownMs) {
254
+ if (elapsed >= activeCooldown) {
245
255
  cb.tripped = false;
246
256
  cb.signalCount = 0;
247
257
  cb.trippedAt = null;
258
+ cb.overrideCooldownMs = null;
248
259
  return false;
249
260
  }
250
261
  return true;
@@ -253,25 +264,31 @@ class AntiSuspension {
253
264
  getCircuitBreakerRemainingMs() {
254
265
  const cb = this.suspensionCircuitBreaker;
255
266
  if (!cb.tripped) return 0;
256
- return Math.max(0, cb.cooldownMs - (Date.now() - cb.trippedAt));
267
+ const activeCooldown = cb.overrideCooldownMs || cb.cooldownMs;
268
+ return Math.max(0, activeCooldown - (Date.now() - cb.trippedAt));
257
269
  }
258
270
 
259
271
  tripCircuitBreaker(reason, durationMs) {
260
272
  const cb = this.suspensionCircuitBreaker;
261
273
  cb.tripped = true;
262
274
  cb.trippedAt = Date.now();
263
- if (durationMs) cb.cooldownMs = durationMs;
275
+ // Store custom duration as a per-trip override so the DEFAULT cooldownMs
276
+ // is never permanently mutated — future organic-signal trips will still
277
+ // use the original 45-minute default.
278
+ cb.overrideCooldownMs = durationMs || null;
264
279
  cb.signalCount = cb.maxSignalsBeforeTrip;
280
+ const activeCooldown = durationMs || cb.cooldownMs;
265
281
  const { utils } = this._getUtils();
266
282
  utils && utils.warn && utils.warn("AntiSuspension",
267
283
  `Circuit breaker manually tripped: ${reason || 'manual'}. ` +
268
- `Cooldown: ${(cb.cooldownMs / 60000).toFixed(1)} min`);
284
+ `Cooldown: ${(activeCooldown / 60000).toFixed(1)} min`);
269
285
  }
270
286
 
271
287
  resetCircuitBreaker() {
272
288
  this.suspensionCircuitBreaker.tripped = false;
273
289
  this.suspensionCircuitBreaker.signalCount = 0;
274
290
  this.suspensionCircuitBreaker.trippedAt = null;
291
+ this.suspensionCircuitBreaker.overrideCooldownMs = null;
275
292
  }
276
293
 
277
294
  async simulateTyping(threadID, messageLength = 50) {
@@ -110,6 +110,13 @@ class AutoReLoginManager {
110
110
 
111
111
  const fbLinkFunc = typeof fbLink === 'function' ? fbLink : () => fbLink;
112
112
 
113
+ // Capture the listening state from the CURRENT ctx BEFORE loginHelper
114
+ // replaces api.ctx with a fresh context object. By the time the login
115
+ // callback fires, api.ctx already points to the new ctx, so checking
116
+ // api.ctx._listeningActive in the callback would always be undefined.
117
+ const wasListening = !!(api && api.ctx && api.ctx._listeningActive);
118
+ const savedListenCallback = (api && api.ctx && api.ctx._lastListenCallback) || null;
119
+
113
120
  await new Promise((resolve, reject) => {
114
121
  loginHelperModel(
115
122
  this.credentials,
@@ -121,35 +128,17 @@ class AutoReLoginManager {
121
128
  }
122
129
 
123
130
  if (api) {
124
- // CRITICAL FIX: Update the live ctx object IN-PLACE instead of
125
- // replacing api.ctx. Every API method closure (sendMessage,
126
- // listenMqtt, etc.) captured the original ctx variable replacing
127
- // the reference leaves all of them with stale tokens forever.
128
- // Updating in-place means all closures immediately see fresh values.
129
- const liveCtx = api.ctx;
130
- if (liveCtx && newApi.ctx) {
131
- const sessionFields = [
132
- 'fb_dtsg', 'jazoest', 'lsd', 'ttstamp',
133
- 'mqttEndpoint', 'lastSeqId', 'appID', 'clientID',
134
- 'userAppID', 'mqttAppID', 'userID', 'region',
135
- 'access_token'
136
- ];
137
- for (const field of sessionFields) {
138
- if (newApi.ctx[field] !== undefined) {
139
- liveCtx[field] = newApi.ctx[field];
140
- }
141
- }
142
- // Reset flags that block MQTT reconnection after re-login
143
- liveCtx.loggedIn = true;
144
- liveCtx._ending = false;
145
- liveCtx._cycling = false;
146
- liveCtx._mqttReauthing = false;
131
+ // loginHelper calls loadApiModules() which recreates all API
132
+ // method closures on the new ctx, so subsequent calls automatically
133
+ // use fresh tokens. Also reset flags on the new ctx so MQTT can
134
+ // reconnect cleanly.
135
+ if (api.ctx) {
136
+ api.ctx.loggedIn = true;
137
+ api.ctx._ending = false;
138
+ api.ctx._cycling = false;
139
+ api.ctx._mqttReauthing = false;
147
140
  }
148
141
 
149
- // Do NOT replace api.defaultFuncs — the existing closures already
150
- // reference the live (now-updated) ctx, so they will pick up fresh
151
- // tokens without needing new function objects.
152
-
153
142
  if (api.tokenRefreshManager) {
154
143
  api.tokenRefreshManager.resetFailureCount();
155
144
  }
@@ -175,15 +164,17 @@ class AutoReLoginManager {
175
164
  this.onReLoginSuccess();
176
165
  }
177
166
 
167
+ // Restart MQTT listening if it was active before re-login.
168
+ // wasListening was captured from the OLD ctx before loginHelper replaced
169
+ // api.ctx — this is the only reliable way to know if listening was on.
178
170
  try {
179
- if (api && api.listenMqtt && api.ctx && api.ctx._listeningActive) {
171
+ if (wasListening && api && api.listenMqtt) {
180
172
  try {
181
173
  if (typeof api.stopListening === 'function') {
182
174
  try { api.stopListening(); } catch (_) {}
183
175
  }
184
- const cb = api.ctx._lastListenCallback || null;
185
- if (cb) {
186
- api.listenMqtt(cb);
176
+ if (savedListenCallback) {
177
+ api.listenMqtt(savedListenCallback);
187
178
  } else {
188
179
  api.listenMqtt();
189
180
  }
@@ -77,6 +77,16 @@ async function inspectResponseForSessionIssues(adapted, ctx) {
77
77
  }
78
78
 
79
79
  if (isScrapingWarning) {
80
+ // Auto-dismiss before propagating the error. Unhandled scraping warnings
81
+ // escalate to permanent suspension. We await the bypass so the checkpoint is
82
+ // gone before the caller's retry (e.g. MQTT reconnect) fires.
83
+ // Guards: only attempt when ctx has jar + globalOptions available.
84
+ if (ctx && ctx.jar && ctx.globalOptions) {
85
+ try {
86
+ const { bypassScrapingWarning } = require('./checkpointBypass');
87
+ await bypassScrapingWarning(ctx.jar, ctx.globalOptions, ctx.userID || null, null);
88
+ } catch (_) {}
89
+ }
80
90
  const err = new Error('Facebook scraping warning checkpoint detected.');
81
91
  err.error = 'checkpoint_scraping';
82
92
  err.res = body;
@@ -91,10 +101,14 @@ async function inspectResponseForSessionIssues(adapted, ctx) {
91
101
  // actual login page — NOT generic strings like '"login.php?"' or
92
102
  // '"login_page"' which Facebook embeds in authenticated page payloads as
93
103
  // navigation links and client-side route names, causing false positives.
104
+ // `/checkpoint/block/?next` is the URL Facebook redirects to when it forces
105
+ // a logout; it appears in the response body as a redirect target and is a
106
+ // reliable signal that the session is gone.
94
107
  const isLoginRedirect =
95
108
  bodyStr.includes('<form id="login_form"') ||
96
109
  bodyStr.includes('id="loginbutton"') ||
97
- bodyStr.includes('id="email" name="email"');
110
+ bodyStr.includes('id="email" name="email"') ||
111
+ bodyStr.includes('/checkpoint/block/?next');
98
112
 
99
113
  const isLoginBlocked =
100
114
  typeof body === 'object' && body !== null && body.error === 1357001;
@@ -23,14 +23,33 @@ function formatCookie(arr, url) {
23
23
  function parseAndCheckLogin(ctx, http, retryCount = 0) {
24
24
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
25
 
26
+ /**
27
+ * Attempt performAutoLogin if available and not already in progress.
28
+ * Returns true if re-login succeeded, false otherwise.
29
+ * Always resets ctx.auto_login in a finally block.
30
+ */
31
+ async function tryAutoLogin() {
32
+ if (ctx.auto_login || typeof ctx.performAutoLogin !== 'function') return false;
33
+ ctx.auto_login = true;
34
+ try {
35
+ const ok = await ctx.performAutoLogin();
36
+ return !!ok;
37
+ } catch (_) {
38
+ return false;
39
+ } finally {
40
+ ctx.auto_login = false;
41
+ }
42
+ }
43
+
26
44
  return async (data) => {
27
45
  if (data.statusCode === 401) {
46
+ warn("Session Status", "Session expired. Triggering auto re-login...");
47
+ await tryAutoLogin();
28
48
  const err = new Error("Session expired - Authentication required");
29
49
  err.error = 401;
30
50
  err.errorCode = 401;
31
51
  err.errorType = "AUTHENTICATION_REQUIRED";
32
52
  err.requiresReLogin = true;
33
- warn("Session Status", "Session expired. Re-login required.");
34
53
  throw err;
35
54
  }
36
55
 
@@ -49,7 +68,6 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
49
68
 
50
69
  await delay(retryTime);
51
70
 
52
- // Guard against undefined Content-Type header before splitting
53
71
  const contentType = (data.request.headers && data.request.headers["content-type"]) || "";
54
72
  if (contentType.split(";")[0].trim() === "multipart/form-data") {
55
73
  const newData = await http.postFormData(
@@ -61,10 +79,6 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
61
79
  );
62
80
  return await parseAndCheckLogin(ctx, http, retryCount)(newData);
63
81
  } else {
64
- // defaultFuncs.post signature: (url, jar, form, ctxx, customHeader)
65
- // The 4th arg must be ctx (not ctx.globalOptions) — passing globalOptions
66
- // here caused the retry to be treated as a raw network call without
67
- // session context, missing auth headers and session inspection.
68
82
  const newData = await http.post(
69
83
  url,
70
84
  ctx.jar,
@@ -107,8 +121,9 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
107
121
  if (res.jsmods && res.jsmods.require && Array.isArray(res.jsmods.require[0]) && res.jsmods.require[0][0] === "Cookie") {
108
122
  res.jsmods.require[0][3][0] = res.jsmods.require[0][3][0].replace("_js_", "");
109
123
  const requireCookie = res.jsmods.require[0][3];
110
- ctx.jar.setCookie(formatCookie(requireCookie, "facebook"), "https://www.facebook.com");
111
- ctx.jar.setCookie(formatCookie(requireCookie, "messenger"), "https://www.messenger.com");
124
+ // Use setCookieSync to avoid async fire-and-forget that drops rotated session cookies
125
+ try { ctx.jar.setCookieSync(formatCookie(requireCookie, "facebook"), "https://www.facebook.com"); } catch (_) {}
126
+ try { ctx.jar.setCookieSync(formatCookie(requireCookie, "messenger"), "https://www.messenger.com"); } catch (_) {}
112
127
  }
113
128
 
114
129
  if (res.jsmods && Array.isArray(res.jsmods.require)) {
@@ -120,6 +135,9 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
120
135
  for (let j = 0; j < ctx.fb_dtsg.length; j++) {
121
136
  ctx.ttstamp += ctx.fb_dtsg.charCodeAt(j);
122
137
  }
138
+ // jazoest MUST stay in sync with fb_dtsg — stale jazoest causes form-submission
139
+ // failures that Facebook treats as tamper attempts and flags as bot activity.
140
+ ctx.jazoest = `2${Array.from(ctx.fb_dtsg).reduce((a, b) => a + b.charCodeAt(0), 0)}`;
123
141
  }
124
142
  }
125
143
  }
@@ -131,12 +149,16 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
131
149
  };
132
150
 
133
151
  if (res.error && SESSION_EXPIRY_CODES[res.error]) {
152
+ warn("Session Status", `${SESSION_EXPIRY_CODES[res.error]} (Code: ${res.error}) — triggering auto re-login`);
153
+ // Fire re-login so the session is refreshed even though this request fails.
154
+ // Awaiting ensures the new session is ready before the error propagates to
155
+ // callers (e.g. MQTT reconnect) that would immediately retry.
156
+ await tryAutoLogin();
134
157
  const err = new Error(SESSION_EXPIRY_CODES[res.error]);
135
158
  err.error = res.error;
136
159
  err.errorCode = res.error;
137
160
  err.errorType = "SESSION_EXPIRED";
138
161
  err.requiresReLogin = true;
139
- warn("Session Status", `${SESSION_EXPIRY_CODES[res.error]} (Code: ${res.error})`);
140
162
  throw err;
141
163
  }
142
164
 
@@ -155,6 +177,10 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
155
177
  err.errorType = res.error === 1357004 ? "CHECKPOINT" : res.error === 1357031 ? "LOCKED" : "BLOCKED";
156
178
  err.requiresReLogin = res.error === 1357004 || res.error === 1357031;
157
179
  warn("Account Status", `${ACCOUNT_ERROR_CODES[res.error]} (Code: ${res.error})`);
180
+ // For checkpoint and locked states, trigger re-login so the session can recover
181
+ if (err.requiresReLogin) {
182
+ await tryAutoLogin();
183
+ }
158
184
  throw err;
159
185
  }
160
186
 
@@ -166,13 +192,26 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
166
192
  }
167
193
 
168
194
  const resStr = JSON.stringify(res);
169
-
195
+
170
196
  if (resStr.includes("XCheckpointFBScrapingWarningController") || resStr.includes("601051028565049")) {
171
- warn("Bot Detection", "Facebook checkpoint detected - scraping warning (601051028565049)");
172
- try {
173
- globalRateLimiter.setEndpointCooldown("__GLOBAL__", 5 * 60 * 1000);
197
+ warn("Bot Detection", "Facebook checkpoint detected - scraping warning (601051028565049) — auto-dismissing");
198
+ try {
199
+ globalRateLimiter.setEndpointCooldown("__GLOBAL__", 5 * 60 * 1000);
174
200
  configureRateLimiter({ maxConcurrentRequests: 2 });
175
201
  } catch (_) {}
202
+ // Auto-dismiss the scraping warning — unhandled warnings escalate to permanent
203
+ // account suspension. This path is reached when inspectResponseForSessionIssues
204
+ // was skipped (ctx._skipSessionInspect or null ctx). We still throw after
205
+ // cleanup: `res` at this point is checkpoint JSON, not a valid API response,
206
+ // so returning it would corrupt the caller. The bypass ensures the NEXT
207
+ // retry finds a clean session.
208
+ try {
209
+ const { bypassScrapingWarning } = require('./checkpointBypass');
210
+ await bypassScrapingWarning(ctx.jar, ctx.globalOptions, ctx.userID, null);
211
+ warn("Bot Detection", "Scraping warning dismissed — checkpoint cleared for next retry");
212
+ } catch (bypassErr) {
213
+ warn("Bot Detection", `Scraping warning bypass failed: ${bypassErr && bypassErr.message ? bypassErr.message : String(bypassErr)}`);
214
+ }
176
215
  const err = new Error("Facebook detected automated behavior - Account may be flagged");
177
216
  err.error = "Bot checkpoint detected";
178
217
  err.errorCode = "CHECKPOINT_SCRAPING";
@@ -184,8 +223,8 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
184
223
  if (resStr.includes("1501092823525282")) {
185
224
  warn("Bot Detection", "Critical bot checkpoint 282 detected! Account requires immediate attention!");
186
225
  log("Please check your Facebook account in a browser and complete any security checks.");
187
- try {
188
- globalRateLimiter.setEndpointCooldown("__GLOBAL__", 10 * 60 * 1000);
226
+ try {
227
+ globalRateLimiter.setEndpointCooldown("__GLOBAL__", 10 * 60 * 1000);
189
228
  configureRateLimiter({ maxConcurrentRequests: 1 });
190
229
  } catch (_) {}
191
230
  const err = new Error("Facebook detected automated behavior - Critical checkpoint required");
@@ -206,7 +245,8 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
206
245
  // anywhere in the body JSON, which it does on authenticated pages as a
207
246
  // navigation/share link, causing false-positive session expiry errors.
208
247
  if (String(res.redirect || "").includes("login.php")) {
209
- warn("Session Status", "Redirected to login page - Session expired");
248
+ warn("Session Status", "Redirected to login page - Session expired — triggering auto re-login");
249
+ await tryAutoLogin();
210
250
  const err = new Error("Session expired - Redirected to login page");
211
251
  err.error = 401;
212
252
  err.errorCode = 401;
@@ -215,13 +255,18 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
215
255
  throw err;
216
256
  }
217
257
 
218
- if (typeof data.body === 'string' && (data.body.includes('<title>Facebook - Log In or Sign Up</title>') || data.body.includes('name="login_form"'))) {
258
+ if (typeof data.body === 'string' && (
259
+ data.body.includes('<title>Facebook - Log In or Sign Up</title>') ||
260
+ data.body.includes('name="login_form"') ||
261
+ data.body.includes('/checkpoint/block/?next')
262
+ )) {
263
+ warn("Session Status", "Detected login page or checkpoint redirect — triggering auto re-login");
264
+ await tryAutoLogin();
219
265
  const err = new Error("Session expired - Redirected to login page");
220
266
  err.error = 401;
221
267
  err.errorCode = 401;
222
268
  err.errorType = "LOGIN_REDIRECT";
223
269
  err.requiresReLogin = true;
224
- warn("Session Status", "Detected login page redirect. Session expired.");
225
270
  throw err;
226
271
  }
227
272
 
@@ -231,26 +276,25 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
231
276
 
232
277
  /**
233
278
  * Saves cookies from a response to the cookie jar.
279
+ * Uses setCookieSync to guarantee cookies are saved before the next request fires.
280
+ * Facebook continuously rotates session cookies (xs, c_user, fr, etc.) — any missed
281
+ * rotation leaves the jar stale and Facebook forces an immediate logout.
282
+ *
234
283
  * @param {Object} jar - The cookie jar instance.
235
284
  * @returns {function(res: Object): Object} A function that processes the response and returns it.
236
285
  */
237
286
  function saveCookies(jar) {
238
287
  return function (res) {
239
- const cookies = res.headers["set-cookie"] || [];
288
+ const cookies = (res.headers && res.headers["set-cookie"]) || [];
240
289
  cookies.forEach(function (c) {
241
- // Always attempt to save every Set-Cookie header to both domains.
242
- // The old guard `c.indexOf(".facebook.com") > -1` silently dropped
243
- // cookies whose domain attribute was `facebook.com` (no dot),
244
- // `www.facebook.com`, or absent entirely. Facebook rotates critical
245
- // session cookies (xs, c_user, fr, etc.) continuously — missing a
246
- // single update causes the jar to go stale and Facebook forces logout.
247
- try { jar.setCookie(c, "https://www.facebook.com"); } catch (_) {}
248
-
249
- // Mirror to messenger.com so MQTT / Messenger API calls stay authed.
290
+ // Save to facebook.com
291
+ try { jar.setCookieSync(c, "https://www.facebook.com"); } catch (_) {}
292
+
293
+ // Mirror to messenger.com so MQTT / Messenger API calls stay authenticated.
250
294
  const c2 = c
251
295
  .replace(/domain=[^;]*/i, "domain=.messenger.com")
252
296
  .replace(/secure;?\s*/i, "");
253
- try { jar.setCookie(c2, "https://www.messenger.com"); } catch (_) {}
297
+ try { jar.setCookieSync(c2, "https://www.messenger.com"); } catch (_) {}
254
298
  });
255
299
  return res;
256
300
  };
@@ -265,7 +309,6 @@ function saveCookies(jar) {
265
309
  function getAccessFromBusiness(jar, Options) {
266
310
  return async function (res) {
267
311
  const html = res ? res.body : null;
268
- // Use the same axios wrapper used everywhere else — "request" module does not exist
269
312
  const { get } = require("./axios");
270
313
  try {
271
314
  const businessRes = await get("https://business.facebook.com/content_management", jar, null, Options, { noRef: true });
@@ -278,14 +321,24 @@ function getAccessFromBusiness(jar, Options) {
278
321
  }
279
322
 
280
323
  /**
281
- * Retrieves all cookies from the jar for both Facebook and Messenger domains.
324
+ * Retrieves all cookies from the jar for both Facebook and Messenger domains,
325
+ * deduplicated by key + domain + path so stale copies don't shadow fresh ones.
282
326
  * @param {Object} jar - The cookie jar instance.
283
327
  * @returns {Array<Object>} An array of cookie objects.
284
328
  */
285
329
  function getAppState(jar) {
286
- return jar
287
- .getCookiesSync("https://www.facebook.com")
288
- .concat(jar.getCookiesSync("https://www.messenger.com"));
330
+ const fbCookies = jar.getCookiesSync("https://www.facebook.com");
331
+ const messengerCookies = jar.getCookiesSync("https://www.messenger.com");
332
+ const seen = new Set();
333
+ const all = [];
334
+ for (const cookie of [...fbCookies, ...messengerCookies]) {
335
+ const id = `${cookie.key}|${cookie.domain || ""}|${cookie.path || "/"}`;
336
+ if (!seen.has(id)) {
337
+ seen.add(id);
338
+ all.push(cookie);
339
+ }
340
+ }
341
+ return all;
289
342
  }
290
343
 
291
344
  module.exports = {