@lazyneoaz/metachat 1.1.3 → 1.1.6

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.
@@ -166,7 +166,7 @@ module.exports = (defaultFuncs, api, ctx) => {
166
166
  const mention = msg.mentions[i];
167
167
  const tag = mention.tag;
168
168
  if (typeof tag !== "string") throw new Error("Mention tags must be strings.");
169
- const offset = (msg.body || "").indexOf(tag, mention.fromIndex || 0);
169
+ const offset = msg.body.indexOf(tag, mention.fromIndex || 0);
170
170
  if (offset < 0) utils.warn("handleMention", 'Mention for "' + tag + '" not found in message string.');
171
171
  const id = mention.id || 0;
172
172
  const emptyChar = '\u200E';
@@ -285,84 +285,77 @@ module.exports = (defaultFuncs, api, ctx) => {
285
285
  return callback(new Error("Dissallowed props: `" + disallowedProperties.join(", ") + "`"));
286
286
  }
287
287
 
288
+ // Declare typing state here so the finally block never hits a ReferenceError
289
+ // in strict mode. These are set below if simulateTyping is enabled.
290
+ let typingTimeout = null;
291
+ let typingStarted = false;
292
+
288
293
  try {
289
- // Apply anti-suspension delay and volume checks for ALL send paths
290
- // (both MQTT and HTTP). This must run before transport selection so the
291
- // circuit-breaker can block sends on either path.
292
- try {
293
- await globalAntiSuspension.prepareBeforeMessage(
294
- Array.isArray(threadID) ? threadID[0] : threadID,
295
- typeof msg === "string" ? msg : (msg.body || "")
296
- );
297
- } catch (suspensionErr) {
298
- // Surface circuit-breaker and volume-limit errors directly to the caller
299
- // instead of silently swallowing them. Bots MUST handle these to avoid
300
- // triggering Facebook's automated-behavior detection.
301
- return callback(suspensionErr);
294
+ // Simulate human typing delay when the option is enabled and there is
295
+ // text to type. The indicator is sent first, we wait the computed delay,
296
+ // then the actual send follows. The finally block stops the indicator.
297
+ if (ctx.globalOptions.simulateTyping && msg.body && typeof api.sendTypingIndicator === 'function') {
298
+ try {
299
+ const typingDelay = await globalAntiSuspension.simulateTyping(threadID, msg.body.length);
300
+ await api.sendTypingIndicator(true, threadID);
301
+ typingStarted = true;
302
+ await new Promise(resolve => {
303
+ typingTimeout = setTimeout(resolve, typingDelay);
304
+ });
305
+ } catch (_) {
306
+ // Typing simulation is best-effort — a failure here must never
307
+ // block the actual message send.
308
+ }
302
309
  }
303
310
 
304
- const mqttReady = !!(ctx.mqttClient && ctx.mqttClient.connected);
305
- const isMultiRecipient = Array.isArray(threadID);
306
- // Primary transport: MQTT when connected (faster, lower overhead).
307
- // Multi-recipient sends always use HTTP (MQTT only handles single thread).
308
- const preferMqtt = mqttReady && !isMultiRecipient && !!api.sendMessageMqtt;
309
-
310
- let result;
311
- let primaryErr;
312
- let primaryOk = false;
313
-
314
311
  try {
315
- if (preferMqtt) {
312
+ const mqttReady = ctx.mqttClient && ctx.mqttClient.connected;
313
+ const isMultiRecipient = Array.isArray(threadID);
314
+ // Track which transport was used so the catch block can fall back to
315
+ // the OTHER transport — retrying the same one that just failed is useless.
316
+ const usedMqtt = mqttReady && !isMultiRecipient && api.sendMessageMqtt;
317
+
318
+ let result;
319
+ if (usedMqtt) {
316
320
  result = await api.sendMessageMqtt(msg, threadID, replyToMessage);
317
321
  } else {
318
322
  result = await sendViaHttp(msg, threadID, replyToMessage, isGroup);
319
323
  }
320
- primaryOk = true;
321
- } catch (err) {
322
- primaryErr = err;
323
- }
324
-
325
- if (primaryOk) {
326
- return callback(null, result);
327
- }
324
+ callback(null, result);
325
+ } catch (sendErr) {
326
+ // Fall back to the OTHER transport, not the one that just failed.
327
+ const mqttNowReady = ctx.mqttClient && ctx.mqttClient.connected;
328
+ const primaryWasMqtt = mqttNowReady && !Array.isArray(threadID) && api.sendMessageMqtt;
328
329
 
329
- // Primary transport failed — fall back to the OTHER transport.
330
- let fallbackOk = false;
331
- let fallbackErr;
332
-
333
- if (preferMqtt) {
334
- // MQTT was primary → fall back to HTTP
335
- try {
336
- result = await sendViaHttp(msg, threadID, replyToMessage, isGroup);
337
- fallbackOk = true;
338
- } catch (e) {
339
- fallbackErr = e;
340
- }
341
- } else {
342
- // HTTP was primary → fall back to MQTT if available
343
- const mqttNow = !!(ctx.mqttClient && ctx.mqttClient.connected);
344
- if (mqttNow && !isMultiRecipient && api.sendMessageMqtt) {
330
+ if (primaryWasMqtt) {
331
+ // MQTT was used (or is available) — fall back to HTTP
345
332
  try {
346
- result = await api.sendMessageMqtt(msg, threadID, replyToMessage);
347
- fallbackOk = true;
348
- } catch (e) {
349
- fallbackErr = e;
333
+ const httpRes = await sendViaHttp(msg, threadID, replyToMessage, isGroup);
334
+ callback(null, httpRes);
335
+ } catch (_httpErr) {
336
+ callback(sendErr);
350
337
  }
351
338
  } else {
352
- fallbackErr = primaryErr;
339
+ // HTTP was used — try MQTT if it has since become available
340
+ if (mqttNowReady && !Array.isArray(threadID) && api.sendMessageMqtt) {
341
+ try {
342
+ const mqttRes = await api.sendMessageMqtt(msg, threadID, replyToMessage);
343
+ callback(null, mqttRes);
344
+ } catch (_mqttErr) {
345
+ callback(sendErr);
346
+ }
347
+ } else {
348
+ callback(sendErr);
349
+ }
353
350
  }
354
- }
355
-
356
- if (fallbackOk) {
357
- return callback(null, result);
358
- } else {
359
- // Both transports failed — report the primary error (most informative)
360
- // with the fallback error attached for diagnostics.
361
- const err = primaryErr;
362
- if (err && fallbackErr && fallbackErr !== primaryErr) {
363
- err.fallbackError = fallbackErr;
351
+ } finally {
352
+ // Stop typing indicator regardless of success or failure.
353
+ // typingTimeout and typingStarted are always declared above so this
354
+ // block can never throw a ReferenceError in strict mode.
355
+ if (typingTimeout) clearTimeout(typingTimeout);
356
+ if (typingStarted) {
357
+ try { await api.sendTypingIndicator(false, threadID); } catch (_) {}
364
358
  }
365
- return callback(err || fallbackErr);
366
359
  }
367
360
  } catch (err) {
368
361
  callback(err);
@@ -142,22 +142,10 @@ module.exports = (defaultFuncs, api, ctx) => {
142
142
  replyToMessage = null;
143
143
  }
144
144
 
145
- // Anti-suspension preparation (circuit-breaker check, smart delay, thread
146
- // throttling, and daily-stat tracking) is the responsibility of the CALLER.
147
- // When invoked through api.sendMessage(), preparation is already done there
148
- // for ALL transport paths (both MQTT and HTTP).
149
- //
150
- // If you call api.sendMessageMqtt() directly (bypassing api.sendMessage()),
151
- // call globalAntiSuspension.prepareBeforeMessage() yourself first and handle
152
- // the circuit-breaker error it may throw — otherwise your bot has no protection.
153
-
154
145
  const normalized = typeof msg === "string" ? { body: msg } : msg;
155
146
  const baseBody = normalized.body != null ? String(normalized.body) : "";
156
147
  const epoch = (BigInt(Date.now()) << 22n).toString();
157
- // Use monotonic ctx.wsReqNumber so concurrent sends never share the same
158
- // request_id, which would cause the ACK listener to match the wrong response.
159
- if (typeof ctx.wsReqNumber !== "number") ctx.wsReqNumber = 0;
160
- const requestId = ++ctx.wsReqNumber;
148
+ const requestId = Math.floor(100 + Math.random() * 900);
161
149
  const otid = utils.generateOfflineThreadingID();
162
150
 
163
151
  const payload0 = {
@@ -229,12 +217,6 @@ module.exports = (defaultFuncs, api, ctx) => {
229
217
  payload0.attachment_fbids = files.map(f => String(Object.values(f)[0]));
230
218
  }
231
219
 
232
- // Use monotonic task IDs so concurrent sends never reuse the same task_id
233
- // within a session, which can confuse Facebook's MQTT deduplication.
234
- if (typeof ctx.wsTaskNumber !== "number") ctx.wsTaskNumber = 0;
235
- const taskId1 = ++ctx.wsTaskNumber;
236
- const taskId2 = ++ctx.wsTaskNumber;
237
-
238
220
  const content = {
239
221
  app_id: "2220391788200892",
240
222
  payload: {
@@ -243,7 +225,7 @@ module.exports = (defaultFuncs, api, ctx) => {
243
225
  label: "46",
244
226
  payload: payload0,
245
227
  queue_name: String(threadID),
246
- task_id: taskId1,
228
+ task_id: 400,
247
229
  failure_count: null,
248
230
  },
249
231
  {
@@ -254,7 +236,7 @@ module.exports = (defaultFuncs, api, ctx) => {
254
236
  sync_group: 1,
255
237
  },
256
238
  queue_name: String(threadID),
257
- task_id: taskId2,
239
+ task_id: 401,
258
240
  failure_count: null,
259
241
  },
260
242
  ],
@@ -47,17 +47,16 @@ module.exports = function (defaultFuncs, api, ctx) {
47
47
  });
48
48
 
49
49
  const messageDefs = [
50
- { label: 1013, queue: ['ai_generated_theme', String(threadID)], app_id: '772021112871879' },
51
- { label: 1037, queue: ['msgr_custom_thread_theme', String(threadID)], app_id: '772021112871879' },
52
- { label: 1028, queue: ['thread_theme_writer', String(threadID)], app_id: '772021112871879' },
53
- // label "43" (thread_theme) is the standard FB operation app_id "2220391788200892" (dongdev confirmed)
54
- { label: 43, queue: 'thread_theme', extra: { source: null, payload: null }, app_id: '2220391788200892' },
50
+ { label: 1013, queue: ['ai_generated_theme', String(threadID)] },
51
+ { label: 1037, queue: ['msgr_custom_thread_theme', String(threadID)] },
52
+ { label: 1028, queue: ['thread_theme_writer', String(threadID)] },
53
+ { label: 43, queue: 'thread_theme', extra: { source: null, payload: null } },
55
54
  ];
56
55
 
57
- const messages = messageDefs.map(({ label, queue, extra, app_id }) => {
56
+ const messages = messageDefs.map(({ label, queue, extra }) => {
58
57
  ctx.wsReqNumber += 1;
59
58
  return {
60
- app_id,
59
+ app_id: '772021112871879',
61
60
  payload: JSON.stringify({
62
61
  epoch_id: parseInt(utils.generateOfflineThreadingID()),
63
62
  tasks: [makeTask(label, queue, extra)],
package/src/apis/theme.js CHANGED
@@ -150,11 +150,11 @@ module.exports = function (defaultFuncs, api, ctx) {
150
150
  };
151
151
 
152
152
  const context = {
153
- app_id: '2220391788200892',
153
+ app_id: ctx.appID,
154
154
  payload: {
155
155
  epoch_id: currentEpochId,
156
156
  tasks: [query],
157
- version_id: '8798795233522156',
157
+ version_id: '24631415369801570',
158
158
  },
159
159
  request_id: request_id,
160
160
  type: 3,
@@ -33,7 +33,7 @@ module.exports = function (defaultFuncs, api, ctx) {
33
33
  const taskId = ++ctx.wsTaskNumber;
34
34
 
35
35
  const content = {
36
- app_id: "2220391788200892",
36
+ app_id: String(ctx.appID || ctx.mqttAppID || "2220391788200892"),
37
37
  payload: JSON.stringify({
38
38
  data_trace_id: null,
39
39
  epoch_id: parseInt(utils.generateOfflineThreadingID()),
@@ -26,7 +26,7 @@ const DEFAULT_OPTIONS = {
26
26
  autoReconnect: true,
27
27
  online: true,
28
28
  emitReady: false,
29
- userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
29
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.7499.182 Safari/537.36",
30
30
  };
31
31
 
32
32
  /**
@@ -74,6 +74,16 @@ async function buildAPI(html, jar, netData, globalOptions, fbLinkFunc, errorRetr
74
74
  let region = mqttEndpoint ? new URL(mqttEndpoint).searchParams.get("region")?.toUpperCase() : undefined;
75
75
  const irisSeqIDMatch = html.match(/irisSeqID:"(.+?)"/);
76
76
  const irisSeqID = irisSeqIDMatch ? irisSeqIDMatch[1] : null;
77
+
78
+ // Extract Facebook client-state tokens that are embedded in every page load.
79
+ // Real browsers include these in every subsequent request to prove the request
80
+ // originated from a genuine page load. Missing them is a strong bot signal.
81
+ const spinRMatch = html.match(/"__spin_r"\s*:\s*(\d+)/);
82
+ const spinTMatch = html.match(/"__spin_t"\s*:\s*(\d+)/);
83
+ const __spin_r = spinRMatch ? parseInt(spinRMatch[1], 10) : null;
84
+ const __spin_t = spinTMatch ? parseInt(spinTMatch[1], 10) : null;
85
+ const __spin_b = 'trunk';
86
+
77
87
  if (globalOptions.bypassRegion && mqttEndpoint) {
78
88
  const currentEndpoint = new URL(mqttEndpoint);
79
89
  currentEndpoint.searchParams.set('region', globalOptions.bypassRegion.toLowerCase());
@@ -116,6 +126,12 @@ async function buildAPI(html, jar, netData, globalOptions, fbLinkFunc, errorRetr
116
126
  validator: globalValidator,
117
127
  _emitter: emitter,
118
128
  ttstamp,
129
+ // Client-state spin tokens extracted from the page HTML.
130
+ // These are included in every subsequent request body to prove the
131
+ // request originated from a real page load (not a standalone script).
132
+ __spin_r,
133
+ __spin_t,
134
+ __spin_b,
119
135
  ...dtsgResult,
120
136
  };
121
137
  const defaultFuncs = utils.makeDefaults(html, userID, ctx);
@@ -459,35 +459,6 @@ class AntiSuspension {
459
459
  return results;
460
460
  }
461
461
 
462
- /**
463
- * Prepare before sending — single delay model.
464
- * Enforces smart delay, thread throttle, and volume limits, respects circuit breaker.
465
- * If the circuit breaker is tripped (checkpoint/suspension detected) or
466
- * volume limits are reached, throws to protect the account.
467
- */
468
- async prepareBeforeMessage(threadID, message) {
469
- if (this.isCircuitBreakerTripped()) {
470
- const remaining = this.getCircuitBreakerRemainingMs();
471
- const { utils } = this._getUtils();
472
- utils && utils.warn && utils.warn("AntiSuspension",
473
- `Circuit breaker is tripped. Blocking message send to protect account. ` +
474
- `Cooldown remaining: ${Math.ceil(remaining / 1000)}s`);
475
- const err = new Error(`Circuit breaker is tripped. Pausing sends to protect account. Retry in ${Math.ceil(remaining / 1000)}s.`);
476
- err.error = 'circuit_breaker_tripped';
477
- err.remainingMs = remaining;
478
- throw err;
479
- }
480
-
481
- const volumeWarning = this.checkVolumeLimit(threadID);
482
- if (volumeWarning) {
483
- const { utils } = this._getUtils();
484
- utils && utils.warn && utils.warn("AntiSuspension", volumeWarning);
485
- }
486
-
487
- await this.enforceThreadThrottling(threadID);
488
- this._incrementDailyStats(threadID);
489
- }
490
-
491
462
  getConfig() {
492
463
  return {
493
464
  messageDelayMs: this.messageDelayMs,
@@ -205,10 +205,7 @@ class AutoReLoginManager {
205
205
 
206
206
  async pauseAPIRequests() {
207
207
  utils.log("AutoReLogin", "Pausing API requests during re-login...");
208
- // Wait long enough for in-flight HTTP requests to drain before we start
209
- // re-logging in. A 1s pause was too short — the session cookies could
210
- // change mid-request, causing follow-up requests to arrive with stale auth.
211
- await new Promise(resolve => setTimeout(resolve, 3000));
208
+ await new Promise(resolve => setTimeout(resolve, 1000));
212
209
  }
213
210
 
214
211
  resolvePendingRequests(success) {
@@ -28,9 +28,6 @@ function sanitizeHeaders(headers) {
28
28
  const out = {};
29
29
  for (const [key, value] of Object.entries(headers)) {
30
30
  if (!key || typeof key !== 'string') continue;
31
- // Skip undefined/null values — these are intentional omissions (e.g.
32
- // 'Upgrade-Insecure-Requests' on XHR requests that browsers don't send).
33
- if (value === undefined || value === null) continue;
34
31
  const cleanKey = key.replace(/[^\x21-\x7E]/g, '').trim();
35
32
  if (!cleanKey) continue;
36
33
  const cleanVal = sanitizeHeaderValue(value);
@@ -62,16 +59,27 @@ function getRandomTimezone() {
62
59
 
63
60
  /**
64
61
  * Generates a comprehensive and realistic set of headers for requests to Facebook.
62
+ *
63
+ * Key fingerprint rules enforced here:
64
+ * - Navigate (page load): Accept = full HTML accept, Upgrade-Insecure-Requests = 1,
65
+ * Sec-Fetch-Dest/Mode/Site = document/navigate/none. NO Origin.
66
+ * - XHR (AJAX/API call): Accept = *\/*, Sec-Fetch-Dest/Mode/Site = empty/cors/same-origin,
67
+ * Origin = host, X-Requested-With = XMLHttpRequest. NO Upgrade-Insecure-Requests.
68
+ *
69
+ * X-Requested-With is sent consistently on all XHR/API calls. Consistency matters —
70
+ * sending it sometimes but not others is itself a detectable pattern.
71
+ *
65
72
  * @param {string} url - The target URL.
66
73
  * @param {object} options - Global options from context.
67
74
  * @param {object} ctx - The application context (containing fb_dtsg, lsd, etc.).
68
75
  * @param {object} customHeader - Any extra headers to merge.
69
- * @param {string} requestType - Type of request: 'xhr' for GraphQL/AJAX or 'navigate' for page navigation
76
+ * @param {string} requestType - 'xhr' for GraphQL/AJAX or 'navigate' for page navigation.
70
77
  * @returns {object} A complete headers object.
71
78
  */
72
79
  function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
73
80
  const persona = options?.persona || 'desktop';
74
81
  const isAndroid = persona === 'android' || persona === 'mobile';
82
+ const isXhr = requestType === 'xhr';
75
83
 
76
84
  let userAgent, secChUa, secChUaFullVersionList, secChUaPlatform, secChUaPlatformVersion;
77
85
  let androidData = null;
@@ -113,8 +121,6 @@ function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
113
121
  const host = new URL(url).hostname;
114
122
  const referer = `https://${host}/`;
115
123
 
116
- const isXhr = requestType === 'xhr';
117
-
118
124
  const locales = options?.cachedLocale || (androidData?.locale ? androidData.locale.replace('_', '-') : getRandomLocale());
119
125
 
120
126
  if (isAndroid) {
@@ -140,12 +146,9 @@ function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
140
146
  if (ctx.region) {
141
147
  headers['X-MSGR-Region'] = ctx.region;
142
148
  }
143
- if (ctx.master) {
144
- const { __spin_r, __spin_b, __spin_t } = ctx.master;
145
- if (__spin_r) headers['X-Fb-Spin-R'] = String(__spin_r);
146
- if (__spin_b) headers['X-Fb-Spin-B'] = String(__spin_b);
147
- if (__spin_t) headers['X-Fb-Spin-T'] = String(__spin_t);
148
- }
149
+ if (ctx.__spin_r != null) headers['X-Fb-Spin-R'] = String(ctx.__spin_r);
150
+ if (ctx.__spin_b) headers['X-Fb-Spin-B'] = String(ctx.__spin_b);
151
+ if (ctx.__spin_t != null) headers['X-Fb-Spin-T'] = String(ctx.__spin_t);
149
152
  }
150
153
 
151
154
  if (customHeader) {
@@ -162,10 +165,14 @@ function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
162
165
  const isMac = secChUaPlatform === '"macOS"';
163
166
  const isLinux = secChUaPlatform === '"Linux"';
164
167
 
168
+ // Navigate (page load) Accept mirrors real Chrome — includes avif/webp/apng
169
+ // XHR Accept is simply */* as sent by Chrome's fetch/XHR APIs
170
+ const acceptHeader = isXhr
171
+ ? '*/*'
172
+ : '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';
173
+
165
174
  const headers = {
166
- 'Accept': isXhr
167
- ? 'application/json, text/plain, */*'
168
- : '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',
175
+ 'Accept': acceptHeader,
169
176
  'Accept-Language': locales,
170
177
  'Accept-Encoding': 'gzip, deflate, br',
171
178
  'Cache-Control': 'no-cache',
@@ -177,26 +184,30 @@ function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
177
184
  'Sec-Ch-Ua': secChUa,
178
185
  'Sec-Ch-Ua-Full-Version-List': secChUaFullVersionList,
179
186
  'Sec-Ch-Ua-Mobile': '?0',
187
+ 'Sec-Ch-Ua-Model': '""',
180
188
  'Sec-Ch-Ua-Platform': secChUaPlatform,
181
189
  'Sec-Ch-Ua-Platform-Version': secChUaPlatformVersion,
182
190
  'Sec-Fetch-Dest': isXhr ? 'empty' : 'document',
183
191
  'Sec-Fetch-Mode': isXhr ? 'cors' : 'navigate',
184
192
  'Sec-Fetch-Site': isXhr ? 'same-origin' : 'none',
185
193
  'User-Agent': userAgent,
186
- 'Upgrade-Insecure-Requests': isXhr ? undefined : '1',
187
- // X-Requested-With is only sent by XHR calls, NOT by page navigations.
188
- // Sending it on navigate requests is a clear bot-detection fingerprint
189
- // because real browsers never include it for document loads.
190
- ...(isXhr ? { 'X-Requested-With': 'XMLHttpRequest' } : {})
191
194
  };
192
195
 
193
- if (isWindows || isMac || isLinux) {
194
- headers['Sec-Ch-Ua-Arch'] = '"x86"';
195
- headers['Sec-Ch-Ua-Bitness'] = '"64"';
196
+ if (!isXhr) {
197
+ headers['Upgrade-Insecure-Requests'] = '1';
196
198
  }
197
199
 
198
200
  if (isXhr) {
199
201
  headers['Origin'] = `https://${host}`;
202
+ // X-Requested-With is sent consistently on all XHR/API requests.
203
+ // Consistency matters — sending it sometimes but not others is itself
204
+ // a detectable pattern. Keep it always-on for API calls.
205
+ headers['X-Requested-With'] = 'XMLHttpRequest';
206
+ }
207
+
208
+ if (isWindows || isMac || isLinux) {
209
+ headers['Sec-Ch-Ua-Arch'] = '"x86"';
210
+ headers['Sec-Ch-Ua-Bitness'] = '"64"';
200
211
  }
201
212
 
202
213
  if (ctx) {
@@ -206,15 +217,13 @@ function getHeaders(url, options, ctx, customHeader, requestType = 'navigate') {
206
217
  if (ctx.region) {
207
218
  headers['X-MSGR-Region'] = ctx.region;
208
219
  }
209
- if (ctx.master) {
210
- const { __spin_r, __spin_b, __spin_t } = ctx.master;
211
- if (__spin_r) headers['X-Fb-Spin-R'] = String(__spin_r);
212
- if (__spin_b) headers['X-Fb-Spin-B'] = String(__spin_b);
213
- if (__spin_t) headers['X-Fb-Spin-T'] = String(__spin_t);
214
- }
220
+ // Spin tokens are stored directly on ctx by buildAPI — ctx.master is
221
+ // never populated and reading from it was dead code that never fired.
222
+ if (ctx.__spin_r != null) headers['X-Fb-Spin-R'] = String(ctx.__spin_r);
223
+ if (ctx.__spin_b) headers['X-Fb-Spin-B'] = String(ctx.__spin_b);
224
+ if (ctx.__spin_t != null) headers['X-Fb-Spin-T'] = String(ctx.__spin_t);
215
225
  }
216
226
 
217
-
218
227
  if (customHeader) {
219
228
  Object.assign(headers, customHeader);
220
229
  if (customHeader.noRef) {
@@ -232,4 +241,4 @@ module.exports = {
232
241
  meta,
233
242
  getRandomLocale,
234
243
  getRandomTimezone
235
- };
244
+ };
@@ -59,7 +59,6 @@ function makeDefaults(html, userID, ctx) {
59
59
  const network = require("./axios");
60
60
  const constants = require("./constants");
61
61
 
62
- let reqCounter = 1;
63
62
  const revision = constants.getFrom(html, 'revision":', ",");
64
63
 
65
64
  /**
@@ -68,10 +67,13 @@ function makeDefaults(html, userID, ctx) {
68
67
  * @returns {object} The merged object.
69
68
  */
70
69
  function mergeWithDefaults(obj) {
70
+ // __req must be random, not a sequential counter. A counter that resets to 1
71
+ // on every session start is a textbook bot fingerprint that Facebook's systems
72
+ // detect. Random values match real Chrome behaviour.
71
73
  const newObj = {
72
74
  av: userID,
73
75
  __user: userID,
74
- __req: (reqCounter++).toString(36),
76
+ __req: Math.floor(Math.random() * 36 ** 2).toString(36),
75
77
  __rev: revision,
76
78
  __a: 1,
77
79
  ...(ctx && {
@@ -80,6 +82,15 @@ function makeDefaults(html, userID, ctx) {
80
82
  }),
81
83
  };
82
84
 
85
+ // Include Facebook's client-state spin tokens when present. These are
86
+ // extracted from the page HTML at login time and prove each request
87
+ // originated from a real page load, not a standalone script.
88
+ if (ctx) {
89
+ if (ctx.__spin_r != null) newObj.__spin_r = ctx.__spin_r;
90
+ if (ctx.__spin_t != null) newObj.__spin_t = ctx.__spin_t;
91
+ if (ctx.__spin_b) newObj.__spin_b = ctx.__spin_b;
92
+ }
93
+
83
94
  if (!obj) return newObj;
84
95
 
85
96
  for (const prop in obj) {
@@ -122,7 +122,7 @@ function buildLsEnvelope(ctx, tasks, versionId = "25459622483894963") {
122
122
  const requestId = ++ctx.wsReqNumber;
123
123
 
124
124
  const envelope = {
125
- app_id: "2220391788200892",
125
+ app_id: String(ctx.appID || ctx.mqttAppID || "2220391788200892"),
126
126
  payload: JSON.stringify({
127
127
  epoch_id: generateEpochId(),
128
128
  tasks,
@@ -14,12 +14,8 @@ class RateLimiter {
14
14
 
15
15
  this.ERROR_CACHE_TTL = 300000;
16
16
  this.COOLDOWN_DURATION = 60000;
17
- // Raised from 50 → 120 req/min. The old limit was too aggressive — it
18
- // throttled legitimate API calls (thread info lookups, getSeqID, markAsRead
19
- // etc.) and caused unnecessary delays that looked bot-like when many
20
- // requests were queued. Facebook's actual rate-limiting kicks in far higher.
21
- this.MAX_REQUESTS_PER_MINUTE = 120;
22
- this.MAX_CONCURRENT_REQUESTS = 8;
17
+ this.MAX_REQUESTS_PER_MINUTE = 50;
18
+ this.MAX_CONCURRENT_REQUESTS = 5;
23
19
 
24
20
  this.activeRequests = 0;
25
21
 
@@ -29,9 +25,7 @@ class RateLimiter {
29
25
 
30
26
  // Per-endpoint sliding windows
31
27
  this._endpointWindows = new Map();
32
- // Raised from 20 → 40 req/min per endpoint. The old value throttled
33
- // burst-write scenarios (e.g. thread hydration after reconnect) unnecessarily.
34
- this._MAX_PER_ENDPOINT_PER_MINUTE = 40;
28
+ this._MAX_PER_ENDPOINT_PER_MINUTE = 20;
35
29
  }
36
30
 
37
31
  configure(opts = {}) {
@@ -111,7 +111,7 @@ class TokenRefreshManager {
111
111
  const resp = await utils.get(
112
112
  probeUrl,
113
113
  ctx.jar,
114
- { reason: 'reconnect', __a: 1, __req: 'probe' },
114
+ { reason: 'reconnect', __a: 1, __req: Math.floor(Math.random() * 36 ** 2).toString(36) },
115
115
  ctx.globalOptions,
116
116
  probeCtx
117
117
  );
@@ -236,6 +236,14 @@ class TokenRefreshManager {
236
236
  ctx.__rev = revisionMatch[1];
237
237
  }
238
238
 
239
+ // Refresh spin tokens alongside fb_dtsg — they are page-state values
240
+ // embedded in every HTML response and must stay current so requests
241
+ // continue to pass Facebook's client-state validation.
242
+ const spinRMatch = html.match(/"__spin_r"\s*:\s*(\d+)/);
243
+ const spinTMatch = html.match(/"__spin_t"\s*:\s*(\d+)/);
244
+ if (spinRMatch) ctx.__spin_r = parseInt(spinRMatch[1], 10);
245
+ if (spinTMatch) ctx.__spin_t = parseInt(spinTMatch[1], 10);
246
+
239
247
  this.lastRefresh = Date.now();
240
248
  this.failureCount = 0;
241
249