@lazyneoaz/metachat 1.0.5 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyneoaz/metachat",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
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,
@@ -1007,7 +1007,32 @@ module.exports = (defaultFuncs, api, ctx, opts) => {
1007
1007
  ? ctx._middleware.wrapCallback(baseCallback)
1008
1008
  : baseCallback;
1009
1009
 
1010
+ // Replace ctx._emitter with the new MessageEmitter, but first migrate
1011
+ // any existing listeners so they are not silently orphaned.
1012
+ // Listeners added via api.on() before listenMqtt() was called live on the
1013
+ // old emitter; without migration they would never fire again.
1014
+ const prevEmitter = ctx._emitter;
1010
1015
  ctx._emitter = msgEmitter;
1016
+ if (prevEmitter && prevEmitter !== msgEmitter && typeof prevEmitter.eventNames === 'function') {
1017
+ try {
1018
+ const LIFECYCLE_EVENTS = [
1019
+ 'sessionExpired', 'checkpoint', 'relogin', 'ready',
1020
+ 'account_inactive', 'checkpoint_282', 'checkpoint_956', 'error'
1021
+ ];
1022
+ for (const event of LIFECYCLE_EVENTS) {
1023
+ const listeners = prevEmitter.rawListeners ? prevEmitter.rawListeners(event) : [];
1024
+ for (const listener of listeners) {
1025
+ msgEmitter.on(event, listener);
1026
+ }
1027
+ }
1028
+ } catch (_) {}
1029
+ }
1030
+
1031
+ // Reset reconnect-blocking flags — calling listenMqtt() always means
1032
+ // "start fresh". Without this, ctx._ending left behind by stopListening()
1033
+ // or emitAuthError() would permanently block scheduleReconnect().
1034
+ ctx._ending = false;
1035
+ ctx._cycling = false;
1011
1036
 
1012
1037
  ctx._listeningActive = true;
1013
1038
  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;
@@ -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');
@@ -121,9 +121,35 @@ class AutoReLoginManager {
121
121
  }
122
122
 
123
123
  if (api) {
124
- api.ctx = newApi.ctx;
125
- api.defaultFuncs = newApi.defaultFuncs;
126
-
124
+ // CRITICAL FIX: Update the live ctx object IN-PLACE instead of
125
+ // replacing api.ctx. Every API method closure (sendMessage,
126
+ // listenMqtt, etc.) captured the original ctx variable — replacing
127
+ // the reference leaves all of them with stale tokens forever.
128
+ // Updating in-place means all closures immediately see fresh values.
129
+ const liveCtx = api.ctx;
130
+ if (liveCtx && newApi.ctx) {
131
+ const sessionFields = [
132
+ 'fb_dtsg', 'jazoest', 'lsd', 'ttstamp',
133
+ 'mqttEndpoint', 'lastSeqId', 'appID', 'clientID',
134
+ 'userAppID', 'mqttAppID', 'userID', 'region',
135
+ 'access_token'
136
+ ];
137
+ for (const field of sessionFields) {
138
+ if (newApi.ctx[field] !== undefined) {
139
+ liveCtx[field] = newApi.ctx[field];
140
+ }
141
+ }
142
+ // Reset flags that block MQTT reconnection after re-login
143
+ liveCtx.loggedIn = true;
144
+ liveCtx._ending = false;
145
+ liveCtx._cycling = false;
146
+ liveCtx._mqttReauthing = false;
147
+ }
148
+
149
+ // Do NOT replace api.defaultFuncs — the existing closures already
150
+ // reference the live (now-updated) ctx, so they will pick up fresh
151
+ // tokens without needing new function objects.
152
+
127
153
  if (api.tokenRefreshManager) {
128
154
  api.tokenRefreshManager.resetFailureCount();
129
155
  }
@@ -216,9 +242,10 @@ class AutoReLoginManager {
216
242
  updateAppState(appState) {
217
243
  if (!this.credentials) return;
218
244
  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
- }
245
+ // Always overwrite with the freshest cookies the old condition was too
246
+ // restrictive and silently skipped updates when credentials.appState was
247
+ // already a valid object, leaving stale cookies in re-login credentials.
248
+ this.credentials.appState = appState;
222
249
  }
223
250
 
224
251
  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
  }