@lazyneoaz/metachat 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyneoaz/metachat",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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.",
@@ -53,13 +53,18 @@ function formatThreadGraphQLResponse(messageThread) {
53
53
  const lastR = messageThread.last_read_receipt;
54
54
  const lastReadTimestamp = lastR?.nodes?.[0]?.timestamp_precise || null;
55
55
 
56
+ // Guard: all_participants or edges can be absent on restricted/deleted threads
57
+ const edges = (messageThread.all_participants && Array.isArray(messageThread.all_participants.edges))
58
+ ? messageThread.all_participants.edges
59
+ : [];
60
+
56
61
  return {
57
62
  threadID: threadID,
58
63
  threadName: messageThread.name,
59
- participantIDs: messageThread.all_participants.edges.map(
64
+ participantIDs: edges.map(
60
65
  (d) => d.node.messaging_actor.id,
61
66
  ),
62
- userInfo: messageThread.all_participants.edges.map((d) => {
67
+ userInfo: edges.map((d) => {
63
68
  const p = d.node.messaging_actor;
64
69
  return {
65
70
  id: p.id,
@@ -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
  }
@@ -1007,7 +999,37 @@ module.exports = (defaultFuncs, api, ctx, opts) => {
1007
999
  ? ctx._middleware.wrapCallback(baseCallback)
1008
1000
  : baseCallback;
1009
1001
 
1002
+ // Replace ctx._emitter with the new MessageEmitter, but first migrate
1003
+ // any existing listeners so they are not silently orphaned.
1004
+ // Listeners added via api.on() before listenMqtt() was called live on the
1005
+ // old emitter; without migration they would never fire again.
1006
+ const prevEmitter = ctx._emitter;
1010
1007
  ctx._emitter = msgEmitter;
1008
+ if (prevEmitter && prevEmitter !== msgEmitter && typeof prevEmitter.eventNames === 'function') {
1009
+ try {
1010
+ const LIFECYCLE_EVENTS = [
1011
+ 'sessionExpired', 'checkpoint', 'relogin', 'ready',
1012
+ 'account_inactive', 'checkpoint_282', 'checkpoint_956', 'error'
1013
+ ];
1014
+ for (const event of LIFECYCLE_EVENTS) {
1015
+ // Use listeners() NOT rawListeners() — rawListeners() returns the
1016
+ // internal once-wrapper functions. When re-registered with .on()
1017
+ // those wrappers fire the handler once then remove themselves from
1018
+ // the OLD emitter, but stay on the NEW emitter forever, causing a
1019
+ // memory leak and never-again-firing once handlers.
1020
+ const fns = prevEmitter.listeners ? prevEmitter.listeners(event) : [];
1021
+ for (const fn of fns) {
1022
+ msgEmitter.on(event, fn);
1023
+ }
1024
+ }
1025
+ } catch (_) {}
1026
+ }
1027
+
1028
+ // Reset reconnect-blocking flags — calling listenMqtt() always means
1029
+ // "start fresh". Without this, ctx._ending left behind by stopListening()
1030
+ // or emitAuthError() would permanently block scheduleReconnect().
1031
+ ctx._ending = false;
1032
+ ctx._cycling = false;
1011
1033
 
1012
1034
  ctx._listeningActive = true;
1013
1035
  ctx._lastListenCallback = callback || null;
@@ -11,50 +11,87 @@ const utils = require('../utils');
11
11
  module.exports = function (defaultFuncs, api, ctx) {
12
12
  /**
13
13
  * Logs the current user out of Facebook.
14
- * @returns {Promise<void>} A promise that resolves when logout is successful or rejects on error.
14
+ *
15
+ * Strategy:
16
+ * 1. Try to fetch the settings-menu endpoint and parse the logout form from
17
+ * the jsmods response (legacy path, may break if Facebook restructures).
18
+ * 2. If that fails for any reason, fall back to posting directly to
19
+ * /logout.php using the session token already in ctx.fb_dtsg. This is
20
+ * reliable as long as the session is still valid.
21
+ *
22
+ * @returns {Promise<void>}
15
23
  */
16
24
  return async function logout() {
17
- const form = {
18
- pmid: "0",
19
- };
20
-
25
+ // ── Path 1: Parse logout form from settings menu ──────────────────────
21
26
  try {
22
27
  const resData = await defaultFuncs
23
28
  .post(
24
29
  "https://www.facebook.com/bluebar/modern_settings_menu/?help_type=364455653583099&show_contextual_help=1",
25
30
  ctx.jar,
26
- form,
31
+ { pmid: "0" },
27
32
  )
28
33
  .then(utils.parseAndCheckLogin(ctx, defaultFuncs));
29
34
 
30
- const elem = resData.jsmods.instances[0][2][0].find(v => v.value === "logout");
31
- if (!elem) {
32
- throw { error: "Could not find logout form element." };
35
+ // Guard every access Facebook restructures this response frequently.
36
+ const instances = resData?.jsmods?.instances;
37
+ const firstGroup = Array.isArray(instances) && instances[0] && instances[0][2] && instances[0][2][0];
38
+ const elem = firstGroup && Array.isArray(firstGroup) ? firstGroup.find(v => v.value === "logout") : null;
39
+
40
+ if (elem) {
41
+ const markup = resData?.jsmods?.markup;
42
+ const markupEntry = Array.isArray(markup) ? markup.find(v => v[0] === elem.markup?.__m) : null;
43
+ const html = markupEntry?.[1]?.__html;
44
+
45
+ if (html) {
46
+ const logoutForm = {
47
+ fb_dtsg: utils.getFrom(html, '"fb_dtsg" value="', '"') || ctx.fb_dtsg,
48
+ ref: utils.getFrom(html, '"ref" value="', '"'),
49
+ h: utils.getFrom(html, '"h" value="', '"'),
50
+ };
51
+
52
+ const logoutRes = await defaultFuncs
53
+ .post("https://www.facebook.com/logout.php", ctx.jar, logoutForm)
54
+ .then(utils.saveCookies(ctx.jar));
55
+
56
+ if (logoutRes.headers && logoutRes.headers.location) {
57
+ await defaultFuncs
58
+ .get(logoutRes.headers.location, ctx.jar)
59
+ .then(utils.saveCookies(ctx.jar));
60
+ ctx.loggedIn = false;
61
+ utils.log("logout", "Logged out successfully (path 1).");
62
+ return;
63
+ }
64
+ // If no redirect location, fall through to path 2
65
+ }
33
66
  }
34
-
35
- const html = resData.jsmods.markup.find(v => v[0] === elem.markup.__m)[1].__html;
36
-
67
+ } catch (_) {
68
+ // Settings-menu path failed continue to fallback
69
+ }
70
+
71
+ // ── Path 2: Direct logout using ctx.fb_dtsg ───────────────────────────
72
+ // This path works as long as the session token in ctx is valid.
73
+ // It does NOT require parsing the settings-menu HTML.
74
+ try {
37
75
  const logoutForm = {
38
- fb_dtsg: utils.getFrom(html, '"fb_dtsg" value="', '"'),
39
- ref: utils.getFrom(html, '"ref" value="', '"'),
40
- h: utils.getFrom(html, '"h" value="', '"'),
76
+ fb_dtsg: ctx.fb_dtsg || "",
77
+ ref: "mb",
78
+ h: "",
41
79
  };
42
80
 
43
81
  const logoutRes = await defaultFuncs
44
82
  .post("https://www.facebook.com/logout.php", ctx.jar, logoutForm)
45
83
  .then(utils.saveCookies(ctx.jar));
46
84
 
47
- if (!logoutRes.headers || !logoutRes.headers.location) {
48
- throw { error: "An error occurred when logging out." };
85
+ if (logoutRes.headers && logoutRes.headers.location) {
86
+ try {
87
+ await defaultFuncs
88
+ .get(logoutRes.headers.location, ctx.jar)
89
+ .then(utils.saveCookies(ctx.jar));
90
+ } catch (_) {}
49
91
  }
50
92
 
51
- await defaultFuncs
52
- .get(logoutRes.headers.location, ctx.jar)
53
- .then(utils.saveCookies(ctx.jar));
54
-
55
93
  ctx.loggedIn = false;
56
- utils.log("logout", "Logged out successfully.");
57
-
94
+ utils.log("logout", "Logged out successfully (path 2).");
58
95
  } catch (err) {
59
96
  utils.error("logout", err);
60
97
  throw err;
@@ -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);
@@ -84,6 +84,13 @@ async function buildAPI(html, jar, netData, globalOptions, fbLinkFunc, errorRetr
84
84
  const emitter = new EventEmitter();
85
85
  emitter.setMaxListeners(50);
86
86
 
87
+ // Pre-compute ttstamp so it is present from the very first request.
88
+ // tokenRefresh.js sets this field on every refresh — initialising it here
89
+ // keeps ctx consistent and prevents undefined reads before the first refresh.
90
+ const ttstamp = dtsgResult.fb_dtsg
91
+ ? "2" + Array.from(dtsgResult.fb_dtsg).map(c => c.charCodeAt(0)).join("")
92
+ : "2";
93
+
87
94
  const ctx = {
88
95
  userID,
89
96
  jar,
@@ -108,6 +115,7 @@ async function buildAPI(html, jar, netData, globalOptions, fbLinkFunc, errorRetr
108
115
  cache: new SimpleCache(),
109
116
  validator: globalValidator,
110
117
  _emitter: emitter,
118
+ ttstamp,
111
119
  ...dtsgResult,
112
120
  };
113
121
  const defaultFuncs = utils.makeDefaults(html, userID, ctx);
@@ -317,13 +317,18 @@ async function loginHelper(credentials, globalOptions, callback, setOptionsFunc,
317
317
 
318
318
  // Expose EventEmitter interface on the API so consumers can subscribe to
319
319
  // key lifecycle events: 'sessionExpired', 'checkpoint', 'relogin', 'ready'
320
- const emitter = ctx._emitter;
321
- if (emitter) {
322
- api.on = (event, listener) => emitter.on(event, listener);
323
- api.once = (event, listener) => emitter.once(event, listener);
324
- api.off = (event, listener) => emitter.removeListener(event, listener);
325
- api.emit = (event, ...args) => emitter.emit(event, ...args);
326
- api.removeAllListeners = (event) => emitter.removeAllListeners(event);
320
+ //
321
+ // IMPORTANT: Use ctx._emitter dynamically (not a captured snapshot).
322
+ // listenMqtt() replaces ctx._emitter with a fresh MessageEmitter on every
323
+ // call. Capturing the initial emitter here would orphan any listeners added
324
+ // via api.on() after listenMqtt() runs — they would never receive events
325
+ // because the emitter they registered on is no longer the active one.
326
+ if (ctx._emitter) {
327
+ api.on = (event, listener) => ctx._emitter.on(event, listener);
328
+ api.once = (event, listener) => ctx._emitter.once(event, listener);
329
+ api.off = (event, listener) => ctx._emitter.removeListener(event, listener);
330
+ api.emit = (event, ...args) => ctx._emitter.emit(event, ...args);
331
+ api.removeAllListeners = (event) => ctx._emitter.removeAllListeners(event);
327
332
  }
328
333
 
329
334
  const { TokenRefreshManager } = require('../../utils/tokenRefresh');
@@ -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,9 +128,17 @@ class AutoReLoginManager {
121
128
  }
122
129
 
123
130
  if (api) {
124
- api.ctx = newApi.ctx;
125
- api.defaultFuncs = newApi.defaultFuncs;
126
-
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;
140
+ }
141
+
127
142
  if (api.tokenRefreshManager) {
128
143
  api.tokenRefreshManager.resetFailureCount();
129
144
  }
@@ -149,15 +164,17 @@ class AutoReLoginManager {
149
164
  this.onReLoginSuccess();
150
165
  }
151
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.
152
170
  try {
153
- if (api && api.listenMqtt && api.ctx && api.ctx._listeningActive) {
171
+ if (wasListening && api && api.listenMqtt) {
154
172
  try {
155
173
  if (typeof api.stopListening === 'function') {
156
174
  try { api.stopListening(); } catch (_) {}
157
175
  }
158
- const cb = api.ctx._lastListenCallback || null;
159
- if (cb) {
160
- api.listenMqtt(cb);
176
+ if (savedListenCallback) {
177
+ api.listenMqtt(savedListenCallback);
161
178
  } else {
162
179
  api.listenMqtt();
163
180
  }
@@ -216,9 +233,10 @@ class AutoReLoginManager {
216
233
  updateAppState(appState) {
217
234
  if (!this.credentials) return;
218
235
  if (!Array.isArray(appState) || appState.length === 0) return;
219
- if (!this.credentials.appState || Array.isArray(this.credentials.appState) || typeof this.credentials.appState === "string") {
220
- this.credentials.appState = appState;
221
- }
236
+ // Always overwrite with the freshest cookies the old condition was too
237
+ // restrictive and silently skipped updates when credentials.appState was
238
+ // already a valid object, leaving stale cookies in re-login credentials.
239
+ this.credentials.appState = appState;
222
240
  }
223
241
 
224
242
  disable() {
@@ -49,22 +49,26 @@ function parseAndCheckLogin(ctx, http, retryCount = 0) {
49
49
 
50
50
  await delay(retryTime);
51
51
 
52
- if (data.request.headers["content-type"].split(";")[0] === "multipart/form-data") {
52
+ // Guard against undefined Content-Type header before splitting
53
+ const contentType = (data.request.headers && data.request.headers["content-type"]) || "";
54
+ if (contentType.split(";")[0].trim() === "multipart/form-data") {
53
55
  const newData = await http.postFormData(
54
56
  url,
55
57
  ctx.jar,
56
58
  data.request.formData,
57
59
  data.request.qs,
58
- ctx.globalOptions,
59
60
  ctx
60
61
  );
61
62
  return await parseAndCheckLogin(ctx, http, retryCount)(newData);
62
63
  } 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.
63
68
  const newData = await http.post(
64
69
  url,
65
70
  ctx.jar,
66
71
  data.request.form,
67
- ctx.globalOptions,
68
72
  ctx
69
73
  );
70
74
  return await parseAndCheckLogin(ctx, http, retryCount)(newData);
@@ -261,9 +265,10 @@ function saveCookies(jar) {
261
265
  function getAccessFromBusiness(jar, Options) {
262
266
  return async function (res) {
263
267
  const html = res ? res.body : null;
264
- const { get } = require("./request");
268
+ // Use the same axios wrapper used everywhere else — "request" module does not exist
269
+ const { get } = require("./axios");
265
270
  try {
266
- const businessRes = await get("https://business.facebook.com/content_management", jar, null, Options, null, { noRef: true });
271
+ const businessRes = await get("https://business.facebook.com/content_management", jar, null, Options, { noRef: true });
267
272
  const token = /"accessToken":"([^.]+)","clientID":/g.exec(businessRes.body)[1];
268
273
  return [html, token];
269
274
  } catch (e) {
@@ -177,58 +177,72 @@ function _formatAttachment(attachment1, attachment2) {
177
177
  attachment1: attachment1,
178
178
  attachment2: attachment2
179
179
  };
180
- case "MessageImage":
180
+ case "MessageImage": {
181
+ // Guard nested objects — Facebook occasionally returns MessageImage with
182
+ // missing preview/thumbnail/large_preview/original_dimensions objects,
183
+ // which causes a crash on property access (e.g. blob.preview.uri).
184
+ const imgThumb = blob.thumbnail || {};
185
+ const imgPrev = blob.preview || {};
186
+ const imgLarge = blob.large_preview || {};
187
+ const imgDims = blob.original_dimensions || {};
181
188
  return {
182
189
  type: "photo",
183
190
  ID: blob.legacy_attachment_id,
184
191
  filename: blob.filename,
185
- thumbnailUrl: blob.thumbnail.uri,
186
- previewUrl: blob.preview.uri,
187
- previewWidth: blob.preview.width,
188
- previewHeight: blob.preview.height,
189
- largePreviewUrl: blob.large_preview.uri,
190
- largePreviewWidth: blob.large_preview.width,
191
- largePreviewHeight: blob.large_preview.height,
192
- url: blob.large_preview.uri,
193
- width: blob.original_dimensions.x,
194
- height: blob.original_dimensions.y,
192
+ thumbnailUrl: imgThumb.uri || null,
193
+ previewUrl: imgPrev.uri || null,
194
+ previewWidth: imgPrev.width || 0,
195
+ previewHeight: imgPrev.height || 0,
196
+ largePreviewUrl: imgLarge.uri || null,
197
+ largePreviewWidth: imgLarge.width || 0,
198
+ largePreviewHeight: imgLarge.height || 0,
199
+ url: imgLarge.uri || null,
200
+ width: imgDims.x || 0,
201
+ height: imgDims.y || 0,
195
202
  name: blob.filename
196
203
  };
197
- case "MessageAnimatedImage":
204
+ }
205
+ case "MessageAnimatedImage": {
206
+ const aniPrev = blob.preview_image || {};
207
+ const aniImg = blob.animated_image || {};
198
208
  return {
199
209
  type: "animated_image",
200
210
  ID: blob.legacy_attachment_id,
201
211
  filename: blob.filename,
202
- previewUrl: blob.preview_image.uri,
203
- previewWidth: blob.preview_image.width,
204
- previewHeight: blob.preview_image.height,
205
- url: blob.animated_image.uri,
206
- width: blob.animated_image.width,
207
- height: blob.animated_image.height,
208
- thumbnailUrl: blob.preview_image.uri,
212
+ previewUrl: aniPrev.uri || null,
213
+ previewWidth: aniPrev.width || 0,
214
+ previewHeight: aniPrev.height || 0,
215
+ url: aniImg.uri || null,
216
+ width: aniImg.width || 0,
217
+ height: aniImg.height || 0,
218
+ thumbnailUrl: aniPrev.uri || null,
209
219
  name: blob.filename,
210
- facebookUrl: blob.animated_image.uri,
211
- rawGifImage: blob.animated_image.uri,
212
- animatedGifUrl: blob.animated_image.uri,
213
- animatedGifPreviewUrl: blob.preview_image.uri,
214
- animatedWebpUrl: blob.animated_image.uri,
215
- animatedWebpPreviewUrl: blob.preview_image.uri
220
+ facebookUrl: aniImg.uri || null,
221
+ rawGifImage: aniImg.uri || null,
222
+ animatedGifUrl: aniImg.uri || null,
223
+ animatedGifPreviewUrl: aniPrev.uri || null,
224
+ animatedWebpUrl: aniImg.uri || null,
225
+ animatedWebpPreviewUrl: aniPrev.uri || null
216
226
  };
217
- case "MessageVideo":
227
+ }
228
+ case "MessageVideo": {
229
+ const vidImg = blob.large_image || {};
230
+ const vidDims = blob.original_dimensions || {};
218
231
  return {
219
232
  type: "video",
220
233
  filename: blob.filename,
221
234
  ID: blob.legacy_attachment_id,
222
- previewUrl: blob.large_image.uri,
223
- previewWidth: blob.large_image.width,
224
- previewHeight: blob.large_image.height,
225
- url: blob.playable_url,
226
- width: blob.original_dimensions.x,
227
- height: blob.original_dimensions.y,
228
- duration: blob.playable_duration_in_ms,
229
- videoType: blob.video_type.toLowerCase(),
230
- thumbnailUrl: blob.large_image.uri
235
+ previewUrl: vidImg.uri || null,
236
+ previewWidth: vidImg.width || 0,
237
+ previewHeight: vidImg.height || 0,
238
+ url: blob.playable_url || null,
239
+ width: vidDims.x || 0,
240
+ height: vidDims.y || 0,
241
+ duration: blob.playable_duration_in_ms || 0,
242
+ videoType: blob.video_type ? blob.video_type.toLowerCase() : "unknown",
243
+ thumbnailUrl: vidImg.uri || null
231
244
  };
245
+ }
232
246
  case "MessageFile":
233
247
  return {
234
248
  type: "file",
@@ -37,17 +37,24 @@ function formatDeltaMessage(m) {
37
37
  isReply: true
38
38
  } : null;
39
39
 
40
+ // Guard: actorFbId can be null on system/admin messages; threadKey can be
41
+ // missing on malformed deltas from Facebook — both crash with .toString().
42
+ const senderID = md.actorFbId != null ? formatID(md.actorFbId.toString()) : "0";
43
+ const threadKey = md.threadKey || {};
44
+ const threadRaw = threadKey.threadFbId || threadKey.otherUserFbId;
45
+ const threadID = threadRaw != null ? formatID(threadRaw.toString()) : "0";
46
+
40
47
  return {
41
48
  type: "message",
42
- senderID: formatID(md.actorFbId.toString()),
49
+ senderID,
43
50
  body: m.delta.body || "",
44
- threadID: formatID((md.threadKey.threadFbId || md.threadKey.otherUserFbId).toString()),
51
+ threadID,
45
52
  messageID: md.messageId,
46
53
  offlineThreadingId: md.offlineThreadingId,
47
54
  attachments: (m.delta.attachments || []).map(v => _formatAttachment(v)),
48
55
  mentions: mentions,
49
56
  timestamp: md.timestamp,
50
- isGroup: !!md.threadKey.threadFbId,
57
+ isGroup: !!threadKey.threadFbId,
51
58
  participantIDs: m.delta.participants,
52
59
  messageReply: messageReply
53
60
  };
@@ -79,24 +86,35 @@ function formatDeltaEvent(m) {
79
86
  logMessageData = m;
80
87
  }
81
88
 
89
+ // Guard: messageMetadata or threadKey may be absent on some delta variants
90
+ const meta = m.messageMetadata || {};
91
+ const evtKey = meta.threadKey || {};
92
+ const evtThreadRaw = evtKey.threadFbId || evtKey.otherUserFbId;
93
+ const evtThreadID = evtThreadRaw != null ? formatID(evtThreadRaw.toString()) : "0";
94
+ const evtMessageID = meta.messageId != null ? meta.messageId.toString() : "";
95
+
82
96
  return {
83
97
  type: "event",
84
- threadID: formatID((m.messageMetadata.threadKey.threadFbId || m.messageMetadata.threadKey.otherUserFbId).toString()),
85
- messageID: m.messageMetadata.messageId.toString(),
98
+ threadID: evtThreadID,
99
+ messageID: evtMessageID,
86
100
  logMessageType,
87
101
  logMessageData,
88
- logMessageBody: m.messageMetadata.adminText,
89
- timestamp: m.messageMetadata.timestamp,
90
- author: m.messageMetadata.actorFbId,
102
+ logMessageBody: meta.adminText,
103
+ timestamp: meta.timestamp,
104
+ author: meta.actorFbId,
91
105
  participantIDs: m.participants
92
106
  };
93
107
  }
94
108
 
95
109
  function formatDeltaReadReceipt(delta) {
110
+ // Guard: threadKey or its sub-fields may be missing in some receipt variants
111
+ const tk = delta.threadKey || {};
112
+ const reader = (tk.otherUserFbId || delta.actorFbId);
113
+ const threadRaw = tk.otherUserFbId || tk.threadFbId;
96
114
  return {
97
- reader: (delta.threadKey.otherUserFbId || delta.actorFbId).toString(),
115
+ reader: reader != null ? reader.toString() : "0",
98
116
  time: delta.actionTimestampMs,
99
- threadID: formatID((delta.threadKey.otherUserFbId || delta.threadKey.threadFbId).toString()),
117
+ threadID: threadRaw != null ? formatID(threadRaw.toString()) : "0",
100
118
  type: "read_receipt"
101
119
  };
102
120
  }