nexus-fca 2.1.3 → 2.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,58 @@
1
1
  # Changelog
2
2
 
3
- ## [2.1.2] - Unreleased - CONTINUOUS IDLE RECOVERY
3
+ ## [2.1.6] - 2025-08-31 - Memory Guard & Queue Sweeping
4
+ ### Added
5
+ - Central lightweight memory guard sweeps: group queue pruning (idle >30m, overflow trim) and pendingEdits TTL sweeper (every 4m).
6
+ - Health metrics extended: memoryGuardRuns, memoryGuardActions, groupQueueDroppedMessages, groupQueueExpiredQueues, groupQueuePrunedThreads, pendingEditSweeps.
7
+ - API: `api.getMemoryMetrics()` returns focused memory-related counters.
8
+ - Typings updated (`EditOptions`, new API methods) in `index.d.ts`.
9
+
10
+ ### Improved
11
+ - Group queue now tracks `lastActive` and enforces idle purge + overflow protection with metrics.
12
+ - Pending edits TTL enforcement separated from resend watchdog for deterministic expiry.
13
+
14
+ ### Notes
15
+ - All guards are low-frequency, low-impact; no change to delivery reliability or safety – only prevention of unbounded growth.
16
+
17
+ ---
18
+
19
+ ## [2.1.5] - 2025-08-28 - PendingEdits & ACK Metrics
20
+ ### Added
21
+ - PendingEdits buffer with cap (default 200) + TTL (5m) + resend attempts (2) + ACK timeout (12s).
22
+ - Automatic edit resend watchdog with safe limits and metrics (editResends, editFailed, pendingEditsDropped, pendingEditsExpired).
23
+ - API: `api.setEditOptions({ maxPendingEdits, editTTLms, ackTimeoutMs, maxResendAttempts })`.
24
+ - Edit ACK integration: pending edit removed on ACK receipt.
25
+ - Health metrics: p95AckLatencyMs, editResends, editFailed.
26
+
27
+ ### Improved
28
+ - Safer edit pipeline: prevents uncontrolled retries, bounds memory, tracks expirations.
29
+ - Enhanced HealthMetrics with percentile latency sample retention (50-sample window).
30
+
31
+ ### Notes
32
+ - Durable outbound queue & metrics exporter planned next.
33
+
34
+ ---
35
+
36
+ ## [2.1.4] - Adaptive Backoff & Core Metrics
37
+ ### Added
38
+ - Adaptive reconnect backoff with jitter (caps at 5 minutes) for safer, stealthier recovery loops.
39
+ - Lazy preflight session validation (skips heavy validation if a recent successful connect occurred) toggle via `api.enableLazyPreflight()`.
40
+ - Health metrics collector (uptime, idle time, reconnect counts, failures, message/ack counters, synthetic keepalives) accessible with `api.getHealthMetrics()` and included in `api.healthCheck()`.
41
+ - Randomized synthetic keepalive interval (55-75s) to reduce detection patterns.
42
+ - Backoff configuration hook: `api.setBackoffOptions()`.
43
+
44
+ ### Improved
45
+ - Reduced noisy session validation on every `listenMqtt` invocation unless needed.
46
+ - More structured error classification feeding metrics (`session_invalid`, `timeout_no_t_ms`, `mqtt_error`, `message_parse`, `not_logged_in`).
47
+
48
+ ### Planned (Next)
49
+ - ACK tracking refinement & resend logic.
50
+ - Pending edits buffer with TTL and cap.
51
+ - Durable outbound queue & health exporter.
52
+
53
+ ---
54
+
55
+ ## [2.1.2] - CONTINUOUS IDLE RECOVERY
4
56
  ### Added
5
57
  - Soft-stale probing at 2 minutes idle (ping + conditional forced reconnect if no events within 5-8s)
6
58
  - Wrapper around `listenMqtt` to automatically feed events into safety heartbeat (`recordEvent`) for precise idle detection
package/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # Nexus-FCA v2.1.0
1
+ # Nexus-FCA v2.1.5
2
+
3
+ <!-- 2.1.5 Highlights (PendingEdits & ACK Metrics) -->
4
+ > New in 2.1.5: PendingEdits buffer (cap + TTL + safe resend), edit ACK watchdog, p95 ACK latency & edit resend/failure metrics, configurable via `api.setEditOptions()`.
2
5
 
3
6
  <p align="center">
4
7
  <!-- Preview image wrapped in link (corrected ibb.co domain) -->
package/index.d.ts CHANGED
@@ -395,6 +395,11 @@ declare module 'nexus-fca' {
395
395
  setMessageReaction: (reaction: string, messageID: string, callback?: (err?: Error) => void, forceCustomReaction?: boolean) => Promise<void>,
396
396
  setMessageReactionMqtt: (reaction: string, messageID: string, threadID: string, callback?: (err?: Error) => void) => Promise<void>,
397
397
  setOptions: (options: Partial<IFCAU_Options>) => void,
398
+ setEditOptions: (opts: EditOptions) => void,
399
+ setBackoffOptions: (opts: { base?: number; max?: number; factor?: number; jitter?: number }) => void,
400
+ enableLazyPreflight: (enable?: boolean) => void,
401
+ getHealthMetrics: () => any,
402
+ getMemoryMetrics: () => { pendingEdits: number; pendingEditsDropped: number; pendingEditsExpired: number; outboundQueueDepth: number; groupQueueDroppedMessages: number; memoryGuardRuns: number; memoryGuardActions: number } | null,
398
403
  setTitle: (newTitle: string, threadID: string, callback?: (err?: Error) => void) => Promise<void>,
399
404
  setTheme: (themeID?: string, threadID: string, callback?: (err?: Error) => void) => Promise<void>,
400
405
  unsendMessage: (messageID: string, callback?: (err?: Error) => void) => Promise<void>,
package/index.js CHANGED
@@ -237,8 +237,19 @@ function buildAPI(globalOptions, html, jar) {
237
237
  wsReqNumber: 0,
238
238
  wsTaskNumber: 0,
239
239
  // Provide safety module reference to lower layers (listenMqtt)
240
- globalSafety
240
+ globalSafety,
241
+ // Pending edit tracking (Stage 2)
242
+ pendingEdits: new Map()
241
243
  };
244
+ // Default edit / resend safety settings
245
+ if(!globalOptions.editSettings){
246
+ globalOptions.editSettings = {
247
+ maxPendingEdits: 200,
248
+ editTTLms: 5*60*1000,
249
+ ackTimeoutMs: 12000,
250
+ maxResendAttempts: 2
251
+ };
252
+ }
242
253
  const api = {
243
254
  setOptions: setOptions.bind(null, globalOptions),
244
255
  getAppState: function getAppState() {
@@ -251,14 +262,31 @@ function buildAPI(globalOptions, html, jar) {
251
262
  );
252
263
  },
253
264
  healthCheck: function(callback) {
254
- // Simple health check: returns status and safeMode info
255
265
  callback(null, {
256
266
  status: 'ok',
257
267
  safeMode,
258
268
  time: new Date().toISOString(),
259
- userID: ctx.userID || null
269
+ userID: ctx.userID || null,
270
+ metrics: ctx.health ? ctx.health.snapshot() : null
260
271
  });
261
272
  },
273
+ getHealthMetrics: function(){ return ctx.health ? ctx.health.snapshot() : null; },
274
+ enableLazyPreflight(enable=true){ ctx.globalOptions.disablePreflight = !enable; },
275
+ setBackoffOptions(opts={}){ ctx.globalOptions.backoff = Object.assign(ctx.globalOptions.backoff||{}, opts); },
276
+ setEditOptions(opts={}){ Object.assign(ctx.globalOptions.editSettings, opts); },
277
+ getMemoryMetrics(){
278
+ if(!ctx.health) return null;
279
+ const snap = ctx.health.snapshot();
280
+ return {
281
+ pendingEdits: snap.pendingEdits,
282
+ pendingEditsDropped: snap.pendingEditsDropped,
283
+ pendingEditsExpired: snap.pendingEditsExpired,
284
+ outboundQueueDepth: snap.outboundQueueDepth,
285
+ groupQueueDroppedMessages: snap.groupQueueDroppedMessages,
286
+ memoryGuardRuns: snap.memoryGuardRuns,
287
+ memoryGuardActions: snap.memoryGuardActions
288
+ };
289
+ }
262
290
  };
263
291
  const defaultFuncs = utils.makeDefaults(html, i_userID || userID, ctx);
264
292
  require("fs")
@@ -300,7 +328,7 @@ function buildAPI(globalOptions, html, jar) {
300
328
  }, 1000 * 60 * 60 * 24);
301
329
  // === Group Queue (No Cooldown, Sequential per group) ===
302
330
  (function initGroupQueue(){
303
- const groupQueues = new Map(); // threadID -> { q: [], sending: false }
331
+ const groupQueues = new Map(); // threadID -> { q: [], sending: false, lastActive: number }
304
332
  const isGroupThread = (tid) => typeof tid === 'string' && tid.length >= 15; // simple heuristic
305
333
  const DIRECT_FN = api.sendMessage; // original
306
334
 
@@ -310,6 +338,8 @@ function buildAPI(globalOptions, html, jar) {
310
338
  api.setGroupQueueCapacity = function(n){ globalOptions.groupQueueMax = n; };
311
339
  api.enableGroupQueue(true);
312
340
  api.setGroupQueueCapacity(100); // allow up to 100 pending per group
341
+ // New: group queue retention policy
342
+ globalOptions.groupQueueIdleMs = 30*60*1000; // 30m idle purge
313
343
 
314
344
  api._sendMessageDirect = DIRECT_FN;
315
345
  api.sendMessage = function(message, threadID, cb){
@@ -317,10 +347,12 @@ function buildAPI(globalOptions, html, jar) {
317
347
  return api._sendMessageDirect(message, threadID, cb);
318
348
  }
319
349
  let entry = groupQueues.get(threadID);
320
- if(!entry){ entry = { q: [], sending: false }; groupQueues.set(threadID, entry); }
350
+ if(!entry){ entry = { q: [], sending: false, lastActive: Date.now() }; groupQueues.set(threadID, entry); }
351
+ entry.lastActive = Date.now();
321
352
  if(entry.q.length >= (globalOptions.groupQueueMax||100)) {
322
353
  // drop oldest (keep newest) to avoid unbounded growth
323
354
  entry.q.shift();
355
+ if(ctx.health) ctx.health.recordGroupQueuePrune(0,0,1);
324
356
  }
325
357
  entry.q.push({ message, threadID, cb });
326
358
  processQueue(threadID, entry);
@@ -349,6 +381,33 @@ function buildAPI(globalOptions, html, jar) {
349
381
  }
350
382
  entry.sending = false;
351
383
  };
384
+
385
+ // Memory guard sweeper (lightweight)
386
+ if(!globalOptions._groupQueueSweeper){
387
+ globalOptions._groupQueueSweeper = setInterval(()=>{
388
+ const now = Date.now();
389
+ let prunedThreads = 0; let expiredQueues = 0; let dropped = 0; let actions = 0;
390
+ for(const [tid, entry] of groupQueues.entries()){
391
+ // Idle purge
392
+ if(now - entry.lastActive > (globalOptions.groupQueueIdleMs||1800000) && !entry.sending){
393
+ if(entry.q.length){ dropped += entry.q.length; }
394
+ groupQueues.delete(tid); expiredQueues++; actions++;
395
+ continue;
396
+ }
397
+ // Hard cap queue length (just in case capacity changed lower)
398
+ const cap = globalOptions.groupQueueMax||100;
399
+ if(entry.q.length > cap){
400
+ const overflow = entry.q.length - cap;
401
+ entry.q.splice(0, overflow); // drop oldest overflow
402
+ dropped += overflow; actions++;
403
+ }
404
+ }
405
+ if((prunedThreads||expiredQueues||dropped) && ctx.health){
406
+ ctx.health.recordGroupQueuePrune(prunedThreads, expiredQueues, dropped);
407
+ ctx.health.recordMemoryGuardRun(actions);
408
+ }
409
+ }, 5*60*1000); // every 5 minutes
410
+ }
352
411
  })();
353
412
  // === End Group Queue ===
354
413
  return {
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ // Lightweight health & metrics tracker for Nexus-FCA (extended Stage 2 + Memory Guard)
3
+ class HealthMetrics {
4
+ constructor() {
5
+ const now = Date.now();
6
+ this.uptimeStart = now;
7
+ this.lastConnectTs = 0;
8
+ this.lastDisconnectTs = 0;
9
+ this.lastMessageTs = 0;
10
+ this.lastErrorTs = 0;
11
+ this.lastErrorType = null;
12
+ this.reconnects = 0;
13
+ this.consecutiveFailures = 0;
14
+ this.messagesReceived = 0;
15
+ this.syntheticKeepAlives = 0;
16
+ this.acksReceived = 0;
17
+ this.pendingEdits = 0;
18
+ this.pendingEditMap = new Map(); // messageID -> { ts, text, attempts }
19
+ this.pendingEditsDropped = 0; // dropped due to cap
20
+ this.pendingEditsExpired = 0; // expired via TTL sweep
21
+ this.outboundQueueDepth = 0;
22
+ this.outboundQueueDropped = 0;
23
+ this.lastAckLatencyMs = null;
24
+ this.avgAckLatencyMs = null;
25
+ this._ackLatencySamples = [];
26
+ this.p95AckLatencyMs = null;
27
+ this.editResends = 0;
28
+ this.editFailed = 0;
29
+ // Memory / queue guard metrics (Stage 3)
30
+ this.memoryGuardRuns = 0;
31
+ this.memoryGuardLastRun = 0;
32
+ this.memoryGuardActions = 0;
33
+ this.groupQueuePrunedThreads = 0;
34
+ this.groupQueueExpiredQueues = 0;
35
+ this.groupQueueDroppedMessages = 0;
36
+ this.pendingEditSweeps = 0;
37
+ }
38
+ onConnect() { this.lastConnectTs = Date.now(); this.consecutiveFailures = 0; }
39
+ onDisconnect() { this.lastDisconnectTs = Date.now(); }
40
+ onMessage() { this.messagesReceived++; this.lastMessageTs = Date.now(); }
41
+ onSynthetic() { this.syntheticKeepAlives++; }
42
+ onAck(latencyMs){
43
+ this.acksReceived++;
44
+ if(typeof latencyMs === 'number'){
45
+ this.lastAckLatencyMs = latencyMs;
46
+ if(this.avgAckLatencyMs == null) this.avgAckLatencyMs = latencyMs; else this.avgAckLatencyMs = Math.round(this.avgAckLatencyMs*0.8 + latencyMs*0.2);
47
+ // track distribution (cap list length for memory safety)
48
+ this._ackLatencySamples.push(latencyMs);
49
+ if(this._ackLatencySamples.length > 50) this._ackLatencySamples.shift();
50
+ this._recalcP95();
51
+ }
52
+ }
53
+ _recalcP95(){
54
+ if(!this._ackLatencySamples.length){ this.p95AckLatencyMs = null; return; }
55
+ const sorted = [...this._ackLatencySamples].sort((a,b)=>a-b);
56
+ const idx = Math.min(sorted.length-1, Math.floor(sorted.length*0.95));
57
+ this.p95AckLatencyMs = sorted[idx];
58
+ }
59
+ onError(type){ this.lastErrorTs = Date.now(); this.lastErrorType = type || 'unknown'; }
60
+ onReconnectScheduled(delay){ this.reconnects++; this.currentBackoffDelay = delay; if(delay > (this.maxObservedBackoff||0)) this.maxObservedBackoff = delay; }
61
+ trackOutbound(depth){ this.outboundQueueDepth = depth; }
62
+ incOutboundDropped(){ this.outboundQueueDropped++; }
63
+ addPendingEdit(mid, text){ this.pendingEditMap.set(mid, { ts: Date.now(), text, attempts: 0 }); this.pendingEdits = this.pendingEditMap.size; }
64
+ markEditResent(mid){ const rec = this.pendingEditMap.get(mid); if(rec){ rec.attempts++; this.editResends++; } }
65
+ markEditFailed(mid){ if(this.pendingEditMap.delete(mid)) { this.pendingEdits = this.pendingEditMap.size; this.editFailed++; } }
66
+ removePendingEdit(mid){ if(this.pendingEditMap.delete(mid)) this.pendingEdits = this.pendingEditMap.size; }
67
+ incPendingEditDropped(){ this.pendingEditsDropped++; }
68
+ incPendingEditExpired(n=1){ this.pendingEditsExpired += n; }
69
+ sweepPendingEdits(ttlMs){
70
+ const now = Date.now();
71
+ let expired = 0;
72
+ for(const [mid, val] of this.pendingEditMap.entries()){
73
+ if(now - val.ts > ttlMs){ this.pendingEditMap.delete(mid); expired++; }
74
+ }
75
+ if(expired) { this.incPendingEditExpired(expired); this.pendingEditSweeps++; }
76
+ this.pendingEdits = this.pendingEditMap.size;
77
+ }
78
+ // Memory guard helpers
79
+ recordMemoryGuardRun(actions=0){ this.memoryGuardRuns++; this.memoryGuardLastRun = Date.now(); this.memoryGuardActions += actions; }
80
+ recordGroupQueuePrune(threads, expiredQueues, droppedMsgs){
81
+ if(threads) this.groupQueuePrunedThreads += threads;
82
+ if(expiredQueues) this.groupQueueExpiredQueues += expiredQueues;
83
+ if(droppedMsgs) this.groupQueueDroppedMessages += droppedMsgs;
84
+ }
85
+ snapshot(){
86
+ const now = Date.now();
87
+ const idleMs = now - (this.lastMessageTs || this.lastConnectTs || now);
88
+ return {
89
+ uptimeMs: now - this.uptimeStart,
90
+ idleMs,
91
+ reconnects: this.reconnects,
92
+ consecutiveFailures: this.consecutiveFailures,
93
+ lastErrorType: this.lastErrorType,
94
+ lastErrorAgoMs: this.lastErrorTs ? now - this.lastErrorTs : null,
95
+ currentBackoffDelay: this.currentBackoffDelay||0,
96
+ maxObservedBackoff: this.maxObservedBackoff||0,
97
+ messagesReceived: this.messagesReceived,
98
+ syntheticKeepAlives: this.syntheticKeepAlives,
99
+ acksReceived: this.acksReceived,
100
+ lastAckLatencyMs: this.lastAckLatencyMs,
101
+ avgAckLatencyMs: this.avgAckLatencyMs,
102
+ p95AckLatencyMs: this.p95AckLatencyMs,
103
+ pendingEdits: this.pendingEdits,
104
+ pendingEditsDropped: this.pendingEditsDropped,
105
+ pendingEditsExpired: this.pendingEditsExpired,
106
+ editResends: this.editResends,
107
+ editFailed: this.editFailed,
108
+ outboundQueueDepth: this.outboundQueueDepth,
109
+ outboundQueueDropped: this.outboundQueueDropped,
110
+ memoryGuardRuns: this.memoryGuardRuns,
111
+ memoryGuardLastRun: this.memoryGuardLastRun,
112
+ memoryGuardActions: this.memoryGuardActions,
113
+ groupQueuePrunedThreads: this.groupQueuePrunedThreads,
114
+ groupQueueExpiredQueues: this.groupQueueExpiredQueues,
115
+ groupQueueDroppedMessages: this.groupQueueDroppedMessages,
116
+ pendingEditSweeps: this.pendingEditSweeps,
117
+ healthy: this.isHealthy(idleMs)
118
+ };
119
+ }
120
+ isHealthy(idleMs){ if (this.consecutiveFailures >= 10) return false; if (this.messagesReceived < 5 && idleMs > 5*60*1000) return false; return true; }
121
+ }
122
+ module.exports = { HealthMetrics };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-fca",
3
- "version": "2.1.3",
3
+ "version": "2.1.6",
4
4
  "description": "A modern, safe, and advanced Facebook Chat API for Node.js with fully integrated Nexus Login System. NPM-ready with ID/password/2FA support, ultra-low ban rate protection, and zero external dependencies.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -1,57 +1,81 @@
1
1
  // Nexus-FCA: Advanced and Safe Facebook Chat API
2
- // editMessage.js - Edit a sent message via MQTT
2
+ // editMessage.js - Edit a sent message via MQTT with PendingEdits buffer & safe resend
3
3
 
4
4
  const { generateOfflineThreadingID } = require('../utils');
5
5
 
6
- function canBeCalled(func) {
7
- try {
8
- Reflect.apply(func, null, []);
9
- return true;
10
- } catch (error) {
11
- return false;
12
- }
13
- }
14
-
15
6
  module.exports = function (defaultFuncs, api, ctx) {
16
7
  return function editMessage(text, messageID, callback) {
8
+ callback = callback || function(){};
17
9
  if (!ctx.mqttClient) {
18
- throw new Error('Not connected to MQTT');
10
+ return callback(new Error('Not connected to MQTT'));
11
+ }
12
+ if(!messageID || typeof text !== 'string') {
13
+ return callback(new Error('Invalid arguments for editMessage'));
14
+ }
15
+ // Safety: manage pending edits buffer with cap & TTL
16
+ const settings = ctx.globalOptions.editSettings || { maxPendingEdits:200, editTTLms:300000, ackTimeoutMs:12000, maxResendAttempts:2 };
17
+ // Drop oldest if capacity reached
18
+ if(ctx.pendingEdits.size >= settings.maxPendingEdits){
19
+ const firstKey = ctx.pendingEdits.keys().next().value;
20
+ if(firstKey){ ctx.pendingEdits.delete(firstKey); if(ctx.health) ctx.health.incPendingEditDropped(); }
19
21
  }
22
+ const now = Date.now();
23
+ ctx.pendingEdits.set(messageID, { text, ts: now, attempts: 0 });
24
+ if(ctx.health) ctx.health.addPendingEdit(messageID, text);
20
25
 
21
26
  ctx.wsReqNumber += 1;
22
27
  ctx.wsTaskNumber += 1;
23
28
 
24
- const queryPayload = {
25
- message_id: messageID,
26
- text: text
27
- };
28
-
29
- const query = {
30
- failure_count: null,
31
- label: '742',
32
- payload: JSON.stringify(queryPayload),
33
- queue_name: 'edit_message',
34
- task_id: ctx.wsTaskNumber
35
- };
36
-
37
- const context = {
38
- app_id: '2220391788200892',
39
- payload: JSON.stringify({
40
- data_trace_id: null,
41
- epoch_id: parseInt(generateOfflineThreadingID()),
42
- tasks: [query],
43
- version_id: '6903494529735864'
44
- }),
45
- request_id: ctx.wsReqNumber,
46
- type: 3
47
- };
48
-
49
- // if (canBeCalled(callback)) {
50
- // ctx.reqCallbacks[ctx.wsReqNumber] = callback;
51
- // }
29
+ const queryPayload = { message_id: messageID, text };
30
+ const query = { failure_count: null, label: '742', payload: JSON.stringify(queryPayload), queue_name: 'edit_message', task_id: ctx.wsTaskNumber };
31
+ const context = { app_id: '2220391788200892', payload: JSON.stringify({ data_trace_id: null, epoch_id: parseInt(generateOfflineThreadingID()), tasks: [query], version_id: '6903494529735864' }), request_id: ctx.wsReqNumber, type: 3 };
52
32
 
53
- ctx.mqttClient.publish('/ls_req', JSON.stringify(context), {
54
- qos: 1, retain: false
55
- });
33
+ try {
34
+ ctx.mqttClient.publish('/ls_req', JSON.stringify(context), { qos:1, retain:false }, (err)=>{
35
+ if(err){
36
+ if(ctx.health) ctx.health.onError('edit_publish_fail');
37
+ return callback(err);
38
+ }
39
+ // Schedule ACK / resend watchdog
40
+ scheduleEditAckWatch(messageID, settings, ctx, api, callback);
41
+ callback(null, { queued:true, messageID });
42
+ });
43
+ } catch (e) {
44
+ if(ctx.health) ctx.health.onError('edit_exception');
45
+ return callback(e);
46
+ }
56
47
  };
57
48
  };
49
+
50
+ function scheduleEditAckWatch(messageID, settings, ctx, api, originalCb){
51
+ if(!settings || !ctx) return;
52
+ const { ackTimeoutMs=12000, maxResendAttempts=2, editTTLms=300000 } = settings;
53
+ setTimeout(()=>{
54
+ const rec = ctx.pendingEdits.get(messageID);
55
+ if(!rec) return; // already acked or removed
56
+ const age = Date.now() - rec.ts;
57
+ if(age > editTTLms){
58
+ ctx.pendingEdits.delete(messageID);
59
+ if(ctx.health){ ctx.health.removePendingEdit(messageID); ctx.health.incPendingEditExpired(); }
60
+ return;
61
+ }
62
+ if(rec.attempts >= maxResendAttempts){
63
+ ctx.pendingEdits.delete(messageID);
64
+ if(ctx.health) ctx.health.markEditFailed(messageID);
65
+ return;
66
+ }
67
+ // Resend
68
+ try {
69
+ rec.attempts++;
70
+ if(ctx.health) ctx.health.markEditResent(messageID);
71
+ const queryPayload = { message_id: messageID, text: rec.text };
72
+ const resend = { failure_count:null, label:'742', payload: JSON.stringify(queryPayload), queue_name:'edit_message', task_id: ++ctx.wsTaskNumber };
73
+ const context = { app_id: '2220391788200892', payload: JSON.stringify({ data_trace_id:null, epoch_id: parseInt(generateOfflineThreadingID()), tasks:[resend], version_id:'6903494529735864' }), request_id: ++ctx.wsReqNumber, type:3 };
74
+ ctx.mqttClient.publish('/ls_req', JSON.stringify(context), { qos:1, retain:false });
75
+ // Chain another watch if still pending
76
+ scheduleEditAckWatch(messageID, settings, ctx, api, originalCb);
77
+ } catch(e){
78
+ if(ctx.health) ctx.health.onError('edit_resend_exception');
79
+ }
80
+ }, ackTimeoutMs);
81
+ }
package/src/listenMqtt.js CHANGED
@@ -11,6 +11,7 @@ var identity = function () {};
11
11
  var form = {};
12
12
  var getSeqID = function () {};
13
13
  const logger = require("../lib/logger.js");
14
+ const { HealthMetrics } = require("../lib/health/HealthMetrics");
14
15
 
15
16
  // Enhanced imports
16
17
  const MqttManager = require("../lib/mqtt/MqttManager");
@@ -38,6 +39,43 @@ const topics = [
38
39
  "/webrtc_response",
39
40
  ];
40
41
  let WebSocket_Global;
42
+ // Adaptive backoff state (per-process singleton like) - tie to ctx
43
+ function getBackoffState(ctx){
44
+ if(!ctx._adaptiveReconnect){
45
+ ctx._adaptiveReconnect = {
46
+ base: 1000, // 1s
47
+ max: 5 * 60 * 1000, // 5m
48
+ factor: 2,
49
+ jitter: 0.25, // 25% random
50
+ current: 0,
51
+ lastResetTs: 0
52
+ };
53
+ }
54
+ return ctx._adaptiveReconnect;
55
+ }
56
+ function computeNextDelay(state){
57
+ if(!state.current) state.current = state.base;
58
+ else state.current = Math.min(state.max, state.current * state.factor);
59
+ // jitter
60
+ const rand = (Math.random() * 2 - 1) * state.jitter; // -j..+j
61
+ const delay = Math.max(500, Math.round(state.current * (1 + rand)));
62
+ return delay;
63
+ }
64
+ function resetBackoff(state){
65
+ state.current = 0;
66
+ state.lastResetTs = Date.now();
67
+ }
68
+ // Build lazy preflight gating
69
+ function shouldRunPreflight(ctx){
70
+ if(ctx.globalOptions.disablePreflight) return false;
71
+ // If we connected successfully within last 10 minutes, skip heavy preflight to reduce surface.
72
+ const now = Date.now();
73
+ const metrics = ctx.health;
74
+ if(metrics && metrics.lastConnectTs && (now - metrics.lastConnectTs) < 10*60*1000){
75
+ return false;
76
+ }
77
+ return true;
78
+ }
41
79
  function buildProxy() {
42
80
  const Proxy = new Transform({
43
81
  objectMode: false,
@@ -128,20 +166,23 @@ function buildStream(options, WebSocket, Proxy) {
128
166
  return Stream;
129
167
  }
130
168
  function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
131
- // Improved preflight with option to disable
132
- if (!ctx.globalOptions.disablePreflight) {
169
+ // Attach health metrics container lazily
170
+ if(!ctx.health) ctx.health = new (require('../lib/health/HealthMetrics').HealthMetrics)();
171
+ const backoff = getBackoffState(ctx);
172
+ const runPreflight = shouldRunPreflight(ctx);
173
+ if (runPreflight) {
133
174
  (async () => {
134
175
  try {
135
- await utils.validateSession(ctx, defaultFuncs, { retries: 2, delayMs: 1000 });
176
+ await utils.validateSession(ctx, defaultFuncs, { retries: 1, delayMs: 1000 });
136
177
  } catch (e) {
137
- // Suppress first failure; only emit if still bad after short grace period
138
178
  setTimeout(() => {
139
179
  utils.validateSession(ctx, defaultFuncs, { retries: 0 }).catch(err2 => {
140
180
  log.error("listenMqtt", "Session invalid after retry: Not logged in.");
141
181
  ctx.loggedIn = false;
182
+ ctx.health.onError('session_invalid');
142
183
  globalCallback({ type: "not_logged_in", error: "Session invalid (post-retry)." });
143
184
  });
144
- }, 2000);
185
+ }, 1500);
145
186
  }
146
187
  })();
147
188
  }
@@ -170,6 +211,7 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
170
211
  p: null,
171
212
  php_override: ""
172
213
  };
214
+ // jitter user agent keep consistent
173
215
  const cookies = ctx.jar.getCookies("https://www.facebook.com").join("; ");
174
216
  let host;
175
217
  if (ctx.mqttEndpoint) {
@@ -233,33 +275,40 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
233
275
  const mqttClient = ctx.mqttClient;
234
276
  global.mqttClient = mqttClient;
235
277
  mqttClient.on('error', function (err) {
236
- log.error("listenMqtt", err);
237
- mqttClient.end();
238
- // classify redirect/login errors surfaced through upstream logic
239
278
  const errMsg = (err && (err.error || err.message || "")).toString();
279
+ ctx.health.onError(errMsg.includes('not logged in') ? 'not_logged_in' : 'mqtt_error');
280
+ log.error("listenMqtt", errMsg);
281
+ try { mqttClient.end(true); } catch(_){ }
240
282
  if (/not logged in|login_redirect|html_login_page/i.test(errMsg)) {
241
283
  ctx.loggedIn = false;
242
284
  return globalCallback({ type: "not_logged_in", error: errMsg });
243
285
  }
244
286
  if (ctx.globalOptions.autoReconnect) {
245
- listenMqtt(defaultFuncs, api, ctx, globalCallback);
287
+ scheduleAdaptiveReconnect(defaultFuncs, api, ctx, globalCallback);
246
288
  } else {
247
289
  utils.checkLiveCookie(ctx, defaultFuncs)
248
- .then(res => {
249
- globalCallback({
250
- type: "stop_listen",
251
- error: "Connection refused: Server unavailable"
252
- }, null);
253
- })
254
- .catch(err => {
255
- globalCallback({
256
- type: "account_inactive",
257
- error: "Maybe your account is blocked by facebook, please login and check at https://facebook.com"
258
- }, null);
259
- });
290
+ .then(() => globalCallback({ type: "stop_listen", error: "Connection refused: Server unavailable" }))
291
+ .catch(() => globalCallback({ type: "account_inactive", error: "Maybe your account is blocked by facebook, please login and check at https://facebook.com" }));
292
+ }
293
+ });
294
+ // Ensure reconnection also triggers on unexpected close without prior error
295
+ mqttClient.on('close', function () {
296
+ ctx.health.onDisconnect();
297
+ if (!ctx.loggedIn) return; // avoid loops if logged out
298
+ if (ctx.globalOptions.autoReconnect) {
299
+ scheduleAdaptiveReconnect(defaultFuncs, api, ctx, globalCallback);
300
+ }
301
+ });
302
+ mqttClient.on('disconnect', function(){
303
+ ctx.health.onDisconnect();
304
+ if (!ctx.loggedIn) return;
305
+ if (ctx.globalOptions.autoReconnect) {
306
+ scheduleAdaptiveReconnect(defaultFuncs, api, ctx, globalCallback);
260
307
  }
261
308
  });
262
309
  mqttClient.on("connect", function () {
310
+ resetBackoff(backoff);
311
+ ctx.health.onConnect();
263
312
  if (ctx.globalSafety) { try { ctx.globalSafety.recordEvent(); } catch(_) {} }
264
313
  if (process.env.OnStatus === undefined) {
265
314
  logger("Nexus-FCA premium features works only with Nexus-Bot framework(Kidding)", "info");
@@ -296,9 +345,11 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
296
345
  JSON.stringify({ make_user_available_when_in_foreground: true }),
297
346
  { qos: 1 }
298
347
  );
348
+ // Replace fixed rTimeout reconnect with health-driven logic
299
349
  const rTimeout = setTimeout(function () {
350
+ ctx.health.onError('timeout_no_t_ms');
300
351
  mqttClient.end();
301
- listenMqtt(defaultFuncs, api, ctx, globalCallback);
352
+ scheduleAdaptiveReconnect(defaultFuncs, api, ctx, globalCallback);
302
353
  }, 5000);
303
354
  ctx.tmsWait = function () {
304
355
  clearTimeout(rTimeout);
@@ -312,15 +363,35 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
312
363
  };
313
364
  });
314
365
  mqttClient.on("message", function (topic, message, _packet) {
366
+ ctx.health.onMessage();
315
367
  if (ctx.globalSafety) { try { ctx.globalSafety.recordEvent(); } catch(_) {} }
316
368
  try {
317
369
  let jsonMessage = Buffer.isBuffer(message)
318
370
  ? Buffer.from(message).toString()
319
371
  : message;
320
- try {
321
- jsonMessage = JSON.parse(jsonMessage);
322
- } catch (e) {
323
- jsonMessage = {};
372
+ try { jsonMessage = JSON.parse(jsonMessage); } catch (e) { jsonMessage = {}; }
373
+ // ACK tracking: detect send acknowledgements with latency hint if present
374
+ if (jsonMessage?.message_ack) {
375
+ const ack = jsonMessage.message_ack;
376
+ const mid = ack.message_id || ack.mid;
377
+ if(mid && ctx._pendingOutbound && ctx._pendingOutbound.has(mid)){
378
+ const started = ctx._pendingOutbound.get(mid);
379
+ ctx._pendingOutbound.delete(mid);
380
+ const latency = Date.now() - started;
381
+ ctx.health.onAck(latency);
382
+ } else {
383
+ ctx.health.onAck();
384
+ }
385
+ // If this ACK corresponds to an edit, clear from pendingEdits
386
+ if(mid && ctx.pendingEdits && ctx.pendingEdits.has(mid)){
387
+ ctx.pendingEdits.delete(mid);
388
+ if(ctx.health) ctx.health.removePendingEdit(mid);
389
+ }
390
+ }
391
+ if (jsonMessage?.type === 'ack') { ctx.health.onAck(); }
392
+ // lightweight ack detection heuristic
393
+ if (jsonMessage?.type === 'ack' || jsonMessage?.message_ack) {
394
+ ctx.health.onAck();
324
395
  }
325
396
  if (jsonMessage.type === "jewel_requests_add") {
326
397
  globalCallback(null, {
@@ -335,53 +406,15 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
335
406
  timestamp: Date.now().toString(),
336
407
  });
337
408
  } else if (topic === "/t_ms") {
338
- if (ctx.tmsWait && typeof ctx.tmsWait == "function") {
339
- ctx.tmsWait();
340
- }
341
- if (jsonMessage.firstDeltaSeqId && jsonMessage.syncToken) {
342
- ctx.lastSeqId = jsonMessage.firstDeltaSeqId;
343
- ctx.syncToken = jsonMessage.syncToken;
344
- }
345
- if (jsonMessage.lastIssuedSeqId) {
346
- ctx.lastSeqId = parseInt(jsonMessage.lastIssuedSeqId);
347
- }
348
- for (const i in jsonMessage.deltas) {
349
- const delta = jsonMessage.deltas[i];
350
- parseDelta(defaultFuncs, api, ctx, globalCallback, {
351
- delta: delta,
352
- });
353
- }
354
- } else if (
355
- topic === "/thread_typing" ||
356
- topic === "/orca_typing_notifications"
357
- ) {
358
- const typ = {
359
- type: "typ",
360
- isTyping: !!jsonMessage.state,
361
- from: jsonMessage.sender_fbid.toString(),
362
- threadID: utils.formatID(
363
- (jsonMessage.thread || jsonMessage.sender_fbid).toString()
364
- ),
365
- };
366
- (function () {
367
- globalCallback(null, typ);
368
- })();
409
+ if (ctx.tmsWait && typeof ctx.tmsWait == "function") { ctx.tmsWait(); }
410
+ if (jsonMessage.firstDeltaSeqId && jsonMessage.syncToken) { ctx.lastSeqId = jsonMessage.firstDeltaSeqId; ctx.syncToken = jsonMessage.syncToken; }
411
+ if (jsonMessage.lastIssuedSeqId) { ctx.lastSeqId = parseInt(jsonMessage.lastIssuedSeqId); }
412
+ for (const i in jsonMessage.deltas) { const delta = jsonMessage.deltas[i]; parseDelta(defaultFuncs, api, ctx, globalCallback, { delta: delta, }); }
413
+ } else if ( topic === "/thread_typing" || topic === "/orca_typing_notifications" ) {
414
+ const typ = { type: "typ", isTyping: !!jsonMessage.state, from: jsonMessage.sender_fbid.toString(), threadID: utils.formatID( (jsonMessage.thread || jsonMessage.sender_fbid).toString() ), };
415
+ (function () { globalCallback(null, typ); })();
369
416
  } else if (topic === "/orca_presence") {
370
- if (!ctx.globalOptions.updatePresence) {
371
- for (const i in jsonMessage.list) {
372
- const data = jsonMessage.list[i];
373
- const userID = data["u"];
374
- const presence = {
375
- type: "presence",
376
- userID: userID.toString(),
377
- timestamp: data["l"] * 1000,
378
- statuses: data["p"],
379
- };
380
- (function () {
381
- globalCallback(null, presence);
382
- })();
383
- }
384
- }
417
+ if (!ctx.globalOptions.updatePresence) { for (const i in jsonMessage.list) { const data = jsonMessage.list[i]; const userID = data["u"]; const presence = { type: "presence", userID: userID.toString(), timestamp: data["l"] * 1000, statuses: data["p"], }; (function () { globalCallback(null, presence); })(); } }
385
418
  } else if (topic == "/ls_resp") {
386
419
  const parsedPayload = JSON.parse(jsonMessage.payload);
387
420
  const reqID = jsonMessage.request_id;
@@ -389,39 +422,36 @@ function listenMqtt(defaultFuncs, api, ctx, globalCallback) {
389
422
  const taskData = ctx["tasks"].get(reqID);
390
423
  const { type: taskType, callback: taskCallback } = taskData;
391
424
  const taskRespData = getTaskResponseData(taskType, parsedPayload);
392
- if (taskRespData == null) {
393
- taskCallback("error", null);
394
- } else {
395
- taskCallback(null, {
396
- type: taskType,
397
- reqID: reqID,
398
- ...taskRespData,
399
- });
400
- }
425
+ if (taskRespData == null) { taskCallback("error", null); } else { taskCallback(null, { type: taskType, reqID: reqID, ...taskRespData, }); }
401
426
  }
402
427
  }
403
428
  } catch (ex) {
429
+ ctx.health.onError('message_parse');
404
430
  console.error("Message parsing error:", ex);
405
431
  if (ex.stack) console.error(ex.stack);
406
432
  return;
407
433
  }
408
434
  });
409
- mqttClient.on("close", function () { if (ctx.globalSafety) { try { ctx.globalSafety._ensureMqttAlive(); } catch(_) {} } });
410
- mqttClient.on("disconnect", () => { if (ctx.globalSafety) { try { ctx.globalSafety._ensureMqttAlive(); } catch(_) {} } });
411
- // Lightweight periodic synthetic event to prevent idle expiry if FB sends nothing
435
+ mqttClient.on("close", function () { ctx.health.onDisconnect(); if (ctx.globalSafety) { try { ctx.globalSafety._ensureMqttAlive(); } catch(_) {} } });
436
+ mqttClient.on("disconnect", () => { ctx.health.onDisconnect(); if (ctx.globalSafety) { try { ctx.globalSafety._ensureMqttAlive(); } catch(_) {} } });
437
+ // Synthetic keepalive with randomized cadence (55-75s) to appear human and keep state alive
412
438
  if (!ctx._syntheticKeepAliveInterval) {
413
439
  ctx._syntheticKeepAliveInterval = setInterval(() => {
414
440
  if (!ctx.mqttClient || !ctx.mqttClient.connected) return;
415
441
  if (ctx.globalSafety) {
416
442
  const idle = Date.now() - ctx.globalSafety._lastEventTs;
417
- // Inject synthetic event every 70s if no real traffic -> keeps timers fresh
418
- if (idle > 65 * 1000) {
419
- ctx.globalSafety.recordEvent();
420
- }
443
+ if (idle > 65 * 1000) { ctx.globalSafety.recordEvent(); ctx.health.onSynthetic(); }
421
444
  }
422
- }, 30000);
445
+ }, 55000 + Math.floor(Math.random()*20000));
423
446
  }
424
447
  }
448
+ function scheduleAdaptiveReconnect(defaultFuncs, api, ctx, globalCallback){
449
+ const state = getBackoffState(ctx);
450
+ const delay = computeNextDelay(state);
451
+ ctx.health.onReconnectScheduled(delay);
452
+ log.warn('listenMqtt', `Reconnecting in ${delay} ms (adaptive backoff)`);
453
+ setTimeout(()=>listenMqtt(defaultFuncs, api, ctx, globalCallback), delay);
454
+ }
425
455
  function getTaskResponseData(taskType, payload) {
426
456
  try {
427
457
  switch (taskType) {
@@ -205,14 +205,26 @@ module.exports = function (defaultFuncs, api, ctx) {
205
205
  task.payload = JSON.stringify(task.payload);
206
206
  });
207
207
  form.payload = JSON.stringify(form.payload);
208
- console.log(global.jsonStringifyColor(form, null, 2));
208
+ try { console.log(global.jsonStringifyColor(form, null, 2)); } catch (_) { }
209
+
210
+ // Stage 2: outbound tracking for ACK latency
211
+ if (!ctx._pendingOutbound) ctx._pendingOutbound = new Map();
212
+ let midCandidate = null;
213
+ try {
214
+ const parsed = JSON.parse(form.payload);
215
+ const firstTask = parsed.tasks && parsed.tasks[0] && JSON.parse(parsed.tasks[0].payload);
216
+ if (firstTask && firstTask.otid) midCandidate = String(firstTask.otid);
217
+ } catch (_) { }
218
+ if (midCandidate) {
219
+ ctx._pendingOutbound.set(midCandidate, Date.now());
220
+ if (ctx.health) ctx.health.trackOutbound(ctx._pendingOutbound.size);
221
+ }
209
222
 
210
223
  return mqttClient.publish("/ls_req", JSON.stringify(form), function (err, data) {
211
224
  if (err) {
212
- console.error('Error publishing message: ', err);
225
+ if (ctx.health) ctx.health.onError('publish_fail');
213
226
  callback(err);
214
227
  } else {
215
- console.log('Message published successfully with data: ', data);
216
228
  callback(null, data);
217
229
  }
218
230
  });