@eryxenx/fca 1.0.0

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/DOCS.md +2712 -0
  3. package/README.md +455 -0
  4. package/func/checkUpdate.js +7 -0
  5. package/func/logAdapter.js +33 -0
  6. package/func/logger.js +48 -0
  7. package/index.d.ts +751 -0
  8. package/index.js +22 -0
  9. package/module/config.js +40 -0
  10. package/module/login.js +133 -0
  11. package/module/loginHelper.js +1365 -0
  12. package/module/options.js +44 -0
  13. package/package.json +98 -0
  14. package/src/api/action/addExternalModule.js +25 -0
  15. package/src/api/action/changeAvatar.js +137 -0
  16. package/src/api/action/changeBio.js +75 -0
  17. package/src/api/action/enableAutoSaveAppState.js +73 -0
  18. package/src/api/action/getCurrentUserID.js +7 -0
  19. package/src/api/action/handleFriendRequest.js +57 -0
  20. package/src/api/action/logout.js +76 -0
  21. package/src/api/action/refreshFb_dtsg.js +48 -0
  22. package/src/api/action/setPostReaction.js +106 -0
  23. package/src/api/action/unfriend.js +54 -0
  24. package/src/api/http/httpGet.js +46 -0
  25. package/src/api/http/httpPost.js +52 -0
  26. package/src/api/http/postFormData.js +47 -0
  27. package/src/api/messaging/addUserToGroup.js +68 -0
  28. package/src/api/messaging/changeAdminStatus.js +126 -0
  29. package/src/api/messaging/changeArchivedStatus.js +55 -0
  30. package/src/api/messaging/changeBlockedStatus.js +48 -0
  31. package/src/api/messaging/changeGroupImage.js +91 -0
  32. package/src/api/messaging/changeNickname.js +70 -0
  33. package/src/api/messaging/changeThreadColor.js +79 -0
  34. package/src/api/messaging/changeThreadEmoji.js +111 -0
  35. package/src/api/messaging/createNewGroup.js +88 -0
  36. package/src/api/messaging/createPoll.js +46 -0
  37. package/src/api/messaging/createThemeAI.js +98 -0
  38. package/src/api/messaging/deleteMessage.js +136 -0
  39. package/src/api/messaging/deleteThread.js +56 -0
  40. package/src/api/messaging/editMessage.js +105 -0
  41. package/src/api/messaging/forwardAttachment.js +57 -0
  42. package/src/api/messaging/forwardMessage.js +134 -0
  43. package/src/api/messaging/getEmojiUrl.js +29 -0
  44. package/src/api/messaging/getFriendsList.js +82 -0
  45. package/src/api/messaging/getMessage.js +829 -0
  46. package/src/api/messaging/getThemePictures.js +62 -0
  47. package/src/api/messaging/handleMessageRequest.js +65 -0
  48. package/src/api/messaging/markAsDelivered.js +57 -0
  49. package/src/api/messaging/markAsRead.js +88 -0
  50. package/src/api/messaging/markAsReadAll.js +56 -0
  51. package/src/api/messaging/markAsSeen.js +68 -0
  52. package/src/api/messaging/muteThread.js +50 -0
  53. package/src/api/messaging/pinMessage.js +115 -0
  54. package/src/api/messaging/removeUserFromGroup.js +62 -0
  55. package/src/api/messaging/resolvePhotoUrl.js +43 -0
  56. package/src/api/messaging/scheduler.js +264 -0
  57. package/src/api/messaging/searchForThread.js +53 -0
  58. package/src/api/messaging/sendBroadcast.js +93 -0
  59. package/src/api/messaging/sendMessage.js +269 -0
  60. package/src/api/messaging/sendTypingIndicator.js +90 -0
  61. package/src/api/messaging/sessionGuard.js +130 -0
  62. package/src/api/messaging/setMessageReaction.js +109 -0
  63. package/src/api/messaging/setTitle.js +124 -0
  64. package/src/api/messaging/shareContact.js +98 -0
  65. package/src/api/messaging/threadColors.js +128 -0
  66. package/src/api/messaging/unsendMessage.js +105 -0
  67. package/src/api/messaging/uploadAttachment.js +492 -0
  68. package/src/api/socket/OldMessage.js +186 -0
  69. package/src/api/socket/core/connectMqtt.js +269 -0
  70. package/src/api/socket/core/emitAuth.js +103 -0
  71. package/src/api/socket/core/getSeqID.js +321 -0
  72. package/src/api/socket/core/getTaskResponseData.js +25 -0
  73. package/src/api/socket/core/parseDelta.js +387 -0
  74. package/src/api/socket/detail/buildStream.js +215 -0
  75. package/src/api/socket/detail/constants.js +28 -0
  76. package/src/api/socket/e2ee/crypto.js +173 -0
  77. package/src/api/socket/e2ee/index.js +925 -0
  78. package/src/api/socket/e2ee/localMediaServer.js +59 -0
  79. package/src/api/socket/e2ee/mediaDecode.js +155 -0
  80. package/src/api/socket/e2ee/native/NOTICE.md +29 -0
  81. package/src/api/socket/e2ee/native/build/messagix.dll +0 -0
  82. package/src/api/socket/e2ee/native/build/messagix.so +0 -0
  83. package/src/api/socket/e2ee/native/lib/index.mjs +1426 -0
  84. package/src/api/socket/e2ee/native/nativeMediaBridge.js +233 -0
  85. package/src/api/socket/e2ee/proto/ArmadilloApplication.proto +281 -0
  86. package/src/api/socket/e2ee/proto/ArmadilloICDC.proto +14 -0
  87. package/src/api/socket/e2ee/proto/ConsumerApplication.proto +232 -0
  88. package/src/api/socket/e2ee/proto/MessageApplication.proto +82 -0
  89. package/src/api/socket/e2ee/proto/MessageTransport.proto +77 -0
  90. package/src/api/socket/e2ee/proto/WACommon.proto +66 -0
  91. package/src/api/socket/e2ee/proto/WAMediaTransport.proto +176 -0
  92. package/src/api/socket/e2ee/proto/proto-writer.ts +76 -0
  93. package/src/api/socket/e2ee/protocol.js +196 -0
  94. package/src/api/socket/e2ee/ratchet.js +219 -0
  95. package/src/api/socket/e2ee/store.js +182 -0
  96. package/src/api/socket/e2ee/vendor/fme/dist/index.cjs +6477 -0
  97. package/src/api/socket/e2ee/vendor/fme/proto/ArmadilloApplication.proto +281 -0
  98. package/src/api/socket/e2ee/vendor/fme/proto/ArmadilloICDC.proto +14 -0
  99. package/src/api/socket/e2ee/vendor/fme/proto/ConsumerApplication.proto +232 -0
  100. package/src/api/socket/e2ee/vendor/fme/proto/MessageApplication.proto +82 -0
  101. package/src/api/socket/e2ee/vendor/fme/proto/MessageTransport.proto +77 -0
  102. package/src/api/socket/e2ee/vendor/fme/proto/WACommon.proto +66 -0
  103. package/src/api/socket/e2ee/vendor/fme/proto/WAMediaTransport.proto +176 -0
  104. package/src/api/socket/listenE2EE.js +75 -0
  105. package/src/api/socket/listenMqtt.js +436 -0
  106. package/src/api/socket/middleware/index.js +216 -0
  107. package/src/api/socket/sendMessage.js +314 -0
  108. package/src/api/socket/sendMessageMqtt.js +69 -0
  109. package/src/api/threads/getThreadHistory.js +664 -0
  110. package/src/api/threads/getThreadInfo.js +329 -0
  111. package/src/api/threads/getThreadList.js +293 -0
  112. package/src/api/threads/getThreadPictures.js +78 -0
  113. package/src/api/users/getUserID.js +65 -0
  114. package/src/api/users/getUserInfo.js +402 -0
  115. package/src/api/users/getUserInfoV2.js +134 -0
  116. package/src/app/MessengerBot.js +209 -0
  117. package/src/app/MessengerContext.js +32 -0
  118. package/src/app/createFcaClient.js +136 -0
  119. package/src/app/threadInfoRealtimeSync.js +284 -0
  120. package/src/core/sendReqMqtt.js +96 -0
  121. package/src/database/helpers.js +53 -0
  122. package/src/database/models/index.js +88 -0
  123. package/src/database/models/thread.js +50 -0
  124. package/src/database/models/user.js +46 -0
  125. package/src/database/threadData.js +94 -0
  126. package/src/database/userData.js +98 -0
  127. package/src/remote/remoteClient.js +123 -0
  128. package/src/utils/broadcast.js +51 -0
  129. package/src/utils/client.js +10 -0
  130. package/src/utils/constants.js +23 -0
  131. package/src/utils/cookies.js +68 -0
  132. package/src/utils/format/attachment.js +357 -0
  133. package/src/utils/format/cookie.js +9 -0
  134. package/src/utils/format/date.js +50 -0
  135. package/src/utils/format/decode.js +44 -0
  136. package/src/utils/format/delta.js +194 -0
  137. package/src/utils/format/ids.js +64 -0
  138. package/src/utils/format/index.js +64 -0
  139. package/src/utils/format/message.js +88 -0
  140. package/src/utils/format/presence.js +132 -0
  141. package/src/utils/format/readTyp.js +44 -0
  142. package/src/utils/format/thread.js +42 -0
  143. package/src/utils/format/utils.js +141 -0
  144. package/src/utils/headers.js +128 -0
  145. package/src/utils/loginParser/autoLogin.js +125 -0
  146. package/src/utils/loginParser/helpers.js +43 -0
  147. package/src/utils/loginParser/index.js +10 -0
  148. package/src/utils/loginParser/parseAndCheckLogin.js +220 -0
  149. package/src/utils/loginParser/textUtils.js +28 -0
  150. package/src/utils/nexca-logger.js +144 -0
  151. package/src/utils/nexca-utils.js +686 -0
  152. package/src/utils/request/client.js +26 -0
  153. package/src/utils/request/config.js +23 -0
  154. package/src/utils/request/defaults.js +46 -0
  155. package/src/utils/request/helpers.js +46 -0
  156. package/src/utils/request/index.js +17 -0
  157. package/src/utils/request/methods.js +163 -0
  158. package/src/utils/request/proxy.js +21 -0
  159. package/src/utils/request/retry.js +77 -0
  160. package/src/utils/request/sanitize.js +49 -0
  161. package/src/utils/versionCheck.js +47 -0
@@ -0,0 +1,925 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * NEXCA E2EE Bridge — Signal Protocol + Noise WebSocket
5
+ *
6
+ * Wraps the `fb-messenger-e2ee` npm package (real dependency, not a bundled
7
+ * vendor copy) and exposes it through NEXCA's own api.e2ee / connectE2EE /
8
+ * listenE2EE surface, so the rest of fca-eryxenx and GoatBot never need to
9
+ * know which E2EE engine is underneath.
10
+ *
11
+
12
+ * Full protocol stack:
13
+ * • @signalapp/libsignal-client — Signal Protocol (Double Ratchet)
14
+ * • Noise_XX_25519_AESGCM_SHA256 WebSocket handshake
15
+ * • WA-binary + Protobuf message encoding
16
+ * • ICDC device registration with Facebook
17
+ */
18
+
19
+ const path = require("path");
20
+ const logger = require("../../../utils/nexca-logger");
21
+ const nativeMediaBridge = require("./native/nativeMediaBridge");
22
+ const { decodeIncomingMedia } = require("./mediaDecode");
23
+ const localMediaServer = require("./localMediaServer");
24
+
25
+ // Simple FIFO-bounded Map.set — evicts the oldest entry once maxSize is
26
+ // exceeded, so long-lived per-message caches (media buffers, thread/sender
27
+ // lookups) can't grow unbounded over a multi-hour/overnight process
28
+ // lifetime and eventually get OOM-killed.
29
+ function boundedSet(map, key, value, maxSize) {
30
+ if (map.size >= maxSize && !map.has(key)) {
31
+ const oldestKey = map.keys().next().value;
32
+ map.delete(oldestKey);
33
+ }
34
+ map.set(key, value);
35
+ return map;
36
+ }
37
+
38
+ function loadFBClient() {
39
+ try {
40
+ // Pre-compiled from HerokeyVN/FB-Messenger-E2EE (fb-messenger-e2ee).
41
+ // Compiled ahead of time (esbuild, CJS, node16 target) and committed
42
+ // directly here because:
43
+ // 1. fb-messenger-e2ee is NOT published on the npm registry — only
44
+ // available as TypeScript source on GitHub.
45
+ // 2. Depending on it as a git/file dependency nested inside
46
+ // fca-eryxenx (itself installed via `github:` in GoatBot-Pro)
47
+ // is unreliable — npm does not consistently build nested
48
+ // non-registry dependencies during a git-dependency install.
49
+ // Shipping plain compiled JS here means zero build step is needed
50
+ // at install time — just `require()`.
51
+ return require("./vendor/fme/dist/index.cjs").FBClient;
52
+ } catch (err) {
53
+ throw new Error(
54
+ "E2EE engine (vendor/fme) failed to load.\n" +
55
+ " Make sure these are installed: @noble/curves, @noble/hashes,\n" +
56
+ " @signalapp/libsignal-client, protobufjs, ws, fca-unofficial\n" +
57
+ " Cause: " + err.message
58
+ );
59
+ }
60
+ }
61
+
62
+ class E2EEBridge {
63
+ constructor(ctx, api, defaultFuncs) {
64
+ this.ctx = ctx;
65
+ this.api = api;
66
+ this.defaultFuncs = defaultFuncs || null;
67
+ this.client = null;
68
+ this.connected = false;
69
+ this._messageCallback = null;
70
+ }
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+ // Setup
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+
76
+ async connect(deviceStorePath, userId) {
77
+ // Guard against concurrent/duplicate connect() calls (e.g. bot code
78
+ // calling api.connectE2EE() explicitly while auto-connect is also
79
+ // running) — without this, two parallel connects race and corrupt
80
+ // `this.client`, causing undefined responses downstream.
81
+ if (this._connectPromise) return this._connectPromise;
82
+ if (this.connected) return Promise.resolve({ userId: this.ctx.userID });
83
+
84
+ this._connectPromise = this._doConnect(deviceStorePath, userId)
85
+ .catch((err) => {
86
+ this._connectPromise = null;
87
+ throw err;
88
+ });
89
+ return this._connectPromise;
90
+ }
91
+
92
+ async _doConnect(deviceStorePath, userId) {
93
+ const fs = require("fs");
94
+
95
+ userId = userId || this.ctx.userID;
96
+
97
+ // Default to .nexca/e2ee_device.json in the user's project directory
98
+ if (!deviceStorePath) {
99
+ deviceStorePath = path.join(process.cwd(), ".nexca", "e2ee_device.json");
100
+ }
101
+
102
+ // Auto-create parent directory — users don't need to create it manually
103
+ try {
104
+ fs.mkdirSync(path.dirname(deviceStorePath), { recursive: true });
105
+ } catch (_) {}
106
+
107
+ logger.info("E2EE", "Device store: " + deviceStorePath);
108
+
109
+ const FBClient = loadFBClient();
110
+
111
+ // Re-use the already-loaded session from NEXCA's cookie jar.
112
+ // fb-messenger-e2ee accepts an in-memory appState directly (array or
113
+ // cookie string) and does its own independent fca-unofficial login
114
+ // internally to derive the CAT/bootstrap material E2EE needs. This is
115
+ // the library's documented, supported flow — it runs alongside NEXCA's
116
+ // own MQTT session without conflict (separate login call, same cookies).
117
+ const appState = this.api.getAppState();
118
+
119
+ this.client = new FBClient({
120
+ appState,
121
+ platform: "facebook",
122
+ });
123
+
124
+ logger.info("E2EE", "Bootstrapping auth via fb-messenger-e2ee (appState login)...");
125
+ const connectResult = await this.client.connect();
126
+ const resolvedUserId = connectResult && connectResult.userId;
127
+
128
+ logger.info("E2EE", "Opening Noise WebSocket (Signal Protocol)...");
129
+ const _e2eeDevicePath = deviceStorePath;
130
+ const _e2eeUserId = resolvedUserId || userId;
131
+ await this.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
132
+
133
+ // Auto-reconnect the Noise WebSocket whenever it drops unexpectedly.
134
+ // `this.connected` stays true through reconnect cycles; it's only set
135
+ // false by an explicit api.e2ee.disconnect() call.
136
+ const _self = this;
137
+ this.client.onEvent("disconnected", function _onE2EEDisconnected() {
138
+ if (!_self.connected) return; // intentional disconnect — skip
139
+ logger.warn("E2EE", "Noise WebSocket disconnected — reconnecting in 5s...");
140
+ setTimeout(async function () {
141
+ if (!_self.connected) return;
142
+ try {
143
+ await _self.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
144
+ logger.success("E2EE", "E2EE WebSocket reconnected.");
145
+ } catch (err) {
146
+ logger.error("E2EE", "E2EE reconnect failed: " + (err && err.message ? err.message : String(err)));
147
+ // Retry again after another 10s if reconnect failed
148
+ if (_self.connected) {
149
+ setTimeout(async function () {
150
+ if (!_self.connected) return;
151
+ try {
152
+ await _self.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
153
+ logger.success("E2EE", "E2EE WebSocket reconnected (retry).");
154
+ } catch (err2) {
155
+ logger.error("E2EE", "E2EE reconnect retry failed: " + (err2 && err2.message ? err2.message : String(err2)));
156
+ }
157
+ }, 10000);
158
+ }
159
+ }
160
+ }, 5000);
161
+ });
162
+
163
+ // Forward incoming E2EE messages to the registered callback.
164
+ this.client.onEvent("e2ee_message", async (msg) => {
165
+ this._senderJidMap = this._senderJidMap || new Map();
166
+ this._mediaCache = this._mediaCache || new Map();
167
+ this._msgThreadMap = this._msgThreadMap || new Map();
168
+ this._msgTextCache = this._msgTextCache || new Map();
169
+ if (msg.id) boundedSet(this._senderJidMap, String(msg.id), msg.senderJid || null, 500);
170
+ if (msg.id && msg.text) boundedSet(this._msgTextCache, String(msg.id), String(msg.text), 500);
171
+
172
+ if (msg.kind && msg.kind !== "text" && msg.media) {
173
+ try {
174
+ const meta = decodeIncomingMedia(msg.kind, msg.media);
175
+ if (meta) boundedSet(this._mediaCache, String(msg.id), meta, 50);
176
+ } catch (err) {
177
+ logger.error("E2EE", "[media-decode] failed to decode incoming " + msg.kind + ": " +
178
+ (err && err.message ? err.message : String(err)));
179
+ }
180
+ }
181
+
182
+ if (!this._messageCallback) return;
183
+
184
+ const senderID =
185
+ msg.senderId ||
186
+ (typeof msg.senderJid === "string" ? msg.senderJid.split(".")[0] : "");
187
+
188
+ // GoatBot (handlerEvents.js, threadsData, etc.) expects plain
189
+ // numeric threadIDs everywhere — DB lookups, isNaN() checks, etc.
190
+ // FME group threads arrive as "<id>@g.us" (WA-binary JID style),
191
+ // which breaks those checks and crashes handlerEvents.js. Strip
192
+ // any "@..." suffix so downstream code sees a plain numeric ID.
193
+ function normalizeThreadId(id) {
194
+ if (typeof id !== "string") return id;
195
+ var at = id.indexOf("@");
196
+ return at === -1 ? id : id.slice(0, at);
197
+ }
198
+ const normalizedThreadId = normalizeThreadId(msg.threadId);
199
+ this._knownE2EEThreads = this._knownE2EEThreads || new Set();
200
+ this._knownE2EEThreads.add(normalizedThreadId);
201
+ if (msg.id) boundedSet(this._msgThreadMap, String(msg.id), normalizedThreadId, 500);
202
+ if (msg.replyTo && msg.replyTo.messageId) boundedSet(this._msgThreadMap, String(msg.replyTo.messageId), normalizedThreadId, 500);
203
+
204
+ // Build mentions: vendor surfaces an array [{ id, text }] or object
205
+ var mentions = {};
206
+ if (Array.isArray(msg.mentions)) {
207
+ msg.mentions.forEach(function(m) {
208
+ if (m && m.id) mentions[m.id] = m.text || "@" + m.id;
209
+ });
210
+ } else if (msg.mentions && typeof msg.mentions === "object") {
211
+ mentions = msg.mentions;
212
+ }
213
+
214
+ const isReply = !!(msg.replyTo && msg.replyTo.messageId);
215
+ if (isReply) {
216
+ try {
217
+ console.log("[E2EE-DEBUG] msg.replyTo raw:", JSON.stringify(msg.replyTo, (k, v) => typeof v === "bigint" ? v.toString() : Buffer.isBuffer(v) ? `<Buffer ${v.length}b>` : v));
218
+ } catch (_) { console.log("[E2EE-DEBUG] msg.replyTo (raw, non-serializable):", msg.replyTo); }
219
+ }
220
+ if (msg.kind && msg.kind !== "text") {
221
+ try {
222
+ console.log("[E2EE-DEBUG] msg.kind=" + msg.kind + " msg.media keys:", msg.media ? Object.keys(msg.media) : null);
223
+ } catch (_) {}
224
+ }
225
+ if (msg.kind === "reaction") {
226
+ try {
227
+ console.log("[E2EE-DEBUG] FULL reaction msg dump:", JSON.stringify(msg, (k, v) => typeof v === "bigint" ? v.toString() : Buffer.isBuffer(v) ? `<Buffer ${v.length}b>` : v, 2));
228
+ } catch (_) { console.log("[E2EE-DEBUG] FULL reaction msg (non-serializable):", msg); }
229
+
230
+ this._messageCallback(null, {
231
+ type: "message_reaction",
232
+ threadID: normalizedThreadId,
233
+ messageID: msg.targetId,
234
+ reaction: msg.reaction,
235
+ senderID: msg.senderId || senderID,
236
+ userID: msg.senderId || senderID,
237
+ isE2EE: true
238
+ });
239
+ return;
240
+ }
241
+
242
+ const event = {
243
+ type: isReply ? "message_reply" : "message",
244
+ senderID,
245
+ threadID: normalizedThreadId,
246
+ body: msg.text || "",
247
+ isE2EE: true,
248
+ isGroup: !!msg.isGroup,
249
+ timestamp: msg.timestampMs || Date.now(),
250
+ messageID: msg.id || "",
251
+ attachments: [],
252
+ mentions,
253
+ args: (msg.text || "").trim().split(/\s+/).filter(Boolean),
254
+ };
255
+
256
+ // Populate ctx.threadTypes so that sendMessage.js can detect this
257
+ // as a DM and route attachments through OldMessage (not MQTT).
258
+ // E2EE DM messages arrive via the Noise WebSocket — not MQTT — so
259
+ // parseDelta never sees them and ctx.threadTypes stays empty unless
260
+ // we populate it here.
261
+ if (!event.isGroup && normalizedThreadId) {
262
+ this.ctx.threadTypes = this.ctx.threadTypes || {};
263
+ this.ctx.threadTypes[String(normalizedThreadId)] = 'dm';
264
+ }
265
+
266
+ // Eagerly resolve any E2EE media on this message itself.
267
+ const ownMeta = this._mediaCache.get(String(msg.id));
268
+ if (ownMeta) {
269
+ try {
270
+ const url = await this.resolveAttachment(ownMeta);
271
+ event.attachments = [{
272
+ type: ownMeta.kind === "image" ? "photo" : ownMeta.kind === "video" ? "video" : ownMeta.kind === "audio" ? "audio" : "file",
273
+ ID: event.messageID,
274
+ url,
275
+ isE2EE: true,
276
+ filename: ownMeta.fileName,
277
+ width: ownMeta.width,
278
+ height: ownMeta.height
279
+ }];
280
+ } catch (err) {
281
+ logger.error("E2EE", "[media-resolve] failed to resolve own attachment: " + (err && err.message ? err.message : String(err)));
282
+ }
283
+ }
284
+
285
+ // Populate messageReply so reply handlers work the same as in MQTT
286
+ if (isReply) {
287
+ const replyText = msg.replyTo.text || this._msgTextCache.get(String(msg.replyTo.messageId)) || "";
288
+ event.messageReply = {
289
+ messageID: msg.replyTo.messageId,
290
+ senderID: msg.replyTo.senderId || "",
291
+ threadID: normalizedThreadId,
292
+ body: replyText,
293
+ args: replyText.trim().split(/\s+/).filter(Boolean),
294
+ isE2EE: true,
295
+ isGroup: !!msg.isGroup,
296
+ mentions: {},
297
+ attachments: []
298
+ };
299
+
300
+ // Resolve the ORIGINAL message's media (if any) from cache — this
301
+ // is what makes reply-based commands like imgur.js work in E2EE.
302
+ const repliedMeta = this._mediaCache.get(String(msg.replyTo.messageId));
303
+ if (repliedMeta) {
304
+ try {
305
+ const url = await this.resolveAttachment(repliedMeta);
306
+ event.messageReply.attachments = [{
307
+ type: repliedMeta.kind === "image" ? "photo" : repliedMeta.kind === "video" ? "video" : repliedMeta.kind === "audio" ? "audio" : "file",
308
+ ID: msg.replyTo.messageId,
309
+ url,
310
+ isE2EE: true,
311
+ filename: repliedMeta.fileName,
312
+ width: repliedMeta.width,
313
+ height: repliedMeta.height
314
+ }];
315
+ } catch (err) {
316
+ logger.error("E2EE", "[media-resolve] failed to resolve replied attachment: " + (err && err.message ? err.message : String(err)));
317
+ }
318
+ }
319
+ }
320
+
321
+ this._messageCallback(null, event);
322
+ });
323
+
324
+ // Incoming E2EE reactions — needed for onReaction handlers (e.g. a
325
+ // reaction-triggered unsend feature) to fire in encrypted threads.
326
+ this.client.onEvent("e2ee_reaction", (r) => {
327
+ console.log("[E2EE-DEBUG] e2ee_reaction fired:", JSON.stringify(r, (k, v) => typeof v === "bigint" ? v.toString() : v));
328
+ if (!this._messageCallback) return;
329
+ const threadID = r.chatJid ? String(r.chatJid).split("@")[0].split(".")[0] : "";
330
+ const senderID = r.senderId || (r.senderJid ? String(r.senderJid).split(".")[0] : "");
331
+ this._messageCallback(null, {
332
+ type: "message_reaction",
333
+ threadID,
334
+ messageID: r.messageId,
335
+ reaction: r.reaction,
336
+ senderID,
337
+ userID: senderID,
338
+ isE2EE: true
339
+ });
340
+ });
341
+
342
+ // (Removed: catch-all debug event logger. It served its purpose while
343
+ // discovering the native engine's event shapes — message, typing,
344
+ // reaction, message_unsend, read_receipt, e2ee_receipt, presence are
345
+ // all known now. Leaving it running printed thousands of lines/hour
346
+ // for every typing indicator across all active threads, which added
347
+ // unnecessary CPU/memory overhead over a multi-hour uptime.)
348
+
349
+ // Surface connection errors so the bot log shows them.
350
+ // Silently ignore DuplicatedMessage errors — these are harmless replays on reconnect.
351
+ this.client.onEvent("error", (err) => {
352
+ if (err && (err.code === 1 || (err.message && err.message.includes("old counter")))) return;
353
+ logger.error("E2EE", "E2EE error: " + (err && err.message ? err.message : String(err)));
354
+ });
355
+
356
+ this.connected = true;
357
+ logger.success("E2EE", "E2EE active — Signal Protocol / Noise WebSocket (vendored)");
358
+
359
+ // Diagnostic: log memory + internal cache sizes every 10 min so a
360
+ // future OOM can be correlated against actual growth data instead of
361
+ // guessing which structure is leaking.
362
+ if (!this._memDiagInterval) {
363
+ this._memDiagInterval = setInterval(() => {
364
+ try {
365
+ const mem = process.memoryUsage();
366
+ const fmtMB = (b) => (b / 1024 / 1024).toFixed(1) + "MB";
367
+ console.log(`[MEM-DIAG] rss=${fmtMB(mem.rss)} heapUsed=${fmtMB(mem.heapUsed)} heapTotal=${fmtMB(mem.heapTotal)} external=${fmtMB(mem.external)} arrayBuffers=${fmtMB(mem.arrayBuffers || 0)}`);
368
+ console.log(`[MEM-DIAG] caches: mediaCache=${this._mediaCache ? this._mediaCache.size : 0} msgThreadMap=${this._msgThreadMap ? this._msgThreadMap.size : 0} msgTextCache=${this._msgTextCache ? this._msgTextCache.size : 0} senderJidMap=${this._senderJidMap ? this._senderJidMap.size : 0} seenGroupMsgIds=${this._seenGroupMsgIds ? this._seenGroupMsgIds.size : 0} knownThreads=${this._knownE2EEThreads ? this._knownE2EEThreads.size : 0} knownGroups=${this._knownE2EEGroups ? this._knownE2EEGroups.size : 0} localMediaServerCache=${localMediaServer.getCacheSize()}`);
369
+ } catch (err) {
370
+ console.log("[MEM-DIAG] logging failed:", err && err.message ? err.message : err);
371
+ }
372
+ }, 10 * 60 * 1000);
373
+ this._memDiagInterval.unref();
374
+ }
375
+
376
+ // GROUP E2EE only: the vendor engine's own group Sender-Key decrypt
377
+ // has an unresolved bug (repeated "missing sender key state" /
378
+ // "ciphertext version too old" errors). The native mautrix-go engine
379
+ // decrypts these correctly, so group messages are received through it
380
+ // instead. DM receiving is untouched — it still goes exclusively
381
+ // through the vendor engine above, unchanged.
382
+ try {
383
+ this._seenGroupMsgIds = this._seenGroupMsgIds || new Set();
384
+ // Fire-and-forget: do NOT await this. The native client's own
385
+ // connect (ICDC device registration etc.) can be slow/unpredictable,
386
+ // and awaiting it here blocked the entire bot's startup ("ready")
387
+ // on it — group E2EE receiving now comes online a few seconds
388
+ // after the rest of the bot instead of gating it.
389
+ nativeMediaBridge.onNativeEvent(this.api.getAppState(), "e2eeMessage", (data) => {
390
+ try {
391
+ if (!data || !data.chatJid || !String(data.chatJid).endsWith("@g.us")) return; // DMs stay on vendor path
392
+ if (!data.id) return;
393
+ if (this._seenGroupMsgIds.has(String(data.id))) return;
394
+ this._seenGroupMsgIds.add(String(data.id));
395
+ if (this._seenGroupMsgIds.size > 2000) {
396
+ // simple cap so this never grows unbounded over a long-running process
397
+ const it = this._seenGroupMsgIds.values();
398
+ for (let i = 0; i < 500; i++) this._seenGroupMsgIds.delete(it.next().value);
399
+ }
400
+
401
+ const threadID = String(data.threadId || String(data.chatJid).split("@")[0]);
402
+ const senderID = data.senderId || (data.senderJid ? String(data.senderJid).split(":")[0].split(".")[0] : "");
403
+
404
+ this._msgThreadMap = this._msgThreadMap || new Map();
405
+ this._msgTextCache = this._msgTextCache || new Map();
406
+ this._senderJidMap = this._senderJidMap || new Map();
407
+ this._knownE2EEThreads = this._knownE2EEThreads || new Set();
408
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
409
+ boundedSet(this._msgThreadMap, String(data.id), threadID, 500);
410
+ if (data.text) boundedSet(this._msgTextCache, String(data.id), String(data.text), 500);
411
+ boundedSet(this._senderJidMap, String(data.id), data.senderJid || null, 500);
412
+ this._knownE2EEThreads.add(threadID);
413
+ this._knownE2EEGroups.add(threadID);
414
+
415
+ const isReply = !!(data.replyTo && data.replyTo.messageId);
416
+ if (isReply) boundedSet(this._msgThreadMap, String(data.replyTo.messageId), threadID, 500);
417
+
418
+ const body = data.text || "";
419
+ const event = {
420
+ type: isReply ? "message_reply" : "message",
421
+ senderID,
422
+ threadID,
423
+ messageID: String(data.id),
424
+ body,
425
+ args: body.trim().split(/\s+/).filter(Boolean),
426
+ attachments: [], // native attachment mapping not wired yet — media in E2EE groups isn't resolved
427
+ mentions: {},
428
+ timestamp: data.timestampMs ? Number(data.timestampMs) : Date.now(),
429
+ isGroup: true,
430
+ isE2EE: true,
431
+ participantIDs: []
432
+ };
433
+
434
+ if (isReply) {
435
+ const replyText = this._msgTextCache.get(String(data.replyTo.messageId)) || "";
436
+ event.messageReply = {
437
+ messageID: data.replyTo.messageId,
438
+ senderID: data.replyTo.senderId || "",
439
+ threadID,
440
+ body: replyText,
441
+ args: replyText.trim().split(/\s+/).filter(Boolean),
442
+ isE2EE: true,
443
+ isGroup: true,
444
+ mentions: {},
445
+ attachments: []
446
+ };
447
+ }
448
+
449
+ if (this._messageCallback) this._messageCallback(null, event);
450
+ } catch (err) {
451
+ logger.error("E2EE", "[native-group] failed to process group message: " +
452
+ (err && err.message ? err.message : String(err)));
453
+ }
454
+ }).then(() => {
455
+ logger.info("E2EE", "[native-group] listening for E2EE group messages via native engine");
456
+ }).catch((err) => {
457
+ logger.error("E2EE", "[native-group] failed to attach native message listener (non-fatal, DM unaffected): " +
458
+ (err && err.message ? err.message : String(err)));
459
+ });
460
+ } catch (err) {
461
+ logger.error("E2EE", "[native-group] failed to attach native message listener (non-fatal, DM unaffected): " +
462
+ (err && err.message ? err.message : String(err)));
463
+ }
464
+
465
+ return this;
466
+ }
467
+
468
+ ensureConnected() {
469
+ if (!this.connected || !this.client) {
470
+ throw new Error("E2EE not connected. Call api.connectE2EE() first.");
471
+ }
472
+ }
473
+
474
+ isConnected() {
475
+ return this.connected;
476
+ }
477
+
478
+ // ─────────────────────────────────────────────────────────────────────────
479
+ // Send API (all go through the Noise WebSocket)
480
+ // ─────────────────────────────────────────────────────────────────────────
481
+
482
+ /**
483
+ * Send a message on an E2EE (or non-E2EE) thread.
484
+ *
485
+ * msg can be:
486
+ * - string → plain text
487
+ * - { body, attachment} → text + one or more readable streams
488
+ *
489
+ * Attachments on E2EE threads are encrypted and sent via the Noise WebSocket.
490
+ * Attachments on non-E2EE threads fall back to api.sendMessage (NEXCA's own).
491
+ */
492
+ async sendMessage(threadId, msg, replyToMessageId) {
493
+ this.ensureConnected();
494
+
495
+ const text = typeof msg === "string" ? msg : (msg && msg.body != null ? String(msg.body) : "");
496
+ const attachment = (msg && typeof msg === "object") ? (msg.attachment || null) : null;
497
+
498
+ this._msgThreadMap = this._msgThreadMap || new Map();
499
+ this._msgTextCache = this._msgTextCache || new Map();
500
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
501
+
502
+ const isGroup = this._knownE2EEGroups.has(String(threadId));
503
+
504
+ if (!attachment && isGroup) {
505
+ // Vendor's own group Sender-Key encryption is broken (see native-group
506
+ // listener above) — group text sends go through native instead.
507
+ const result = await nativeMediaBridge.sendGroupMessage(this.api.getAppState(), threadId, text, replyToMessageId);
508
+ if (result && result.messageId) {
509
+ boundedSet(this._msgThreadMap, String(result.messageId), threadId, 500);
510
+ if (text) boundedSet(this._msgTextCache, String(result.messageId), text, 500);
511
+ }
512
+ return result;
513
+ }
514
+
515
+ if (!attachment) {
516
+ const result = await this.client.sendMessage({ threadId, text, replyToMessageId });
517
+ if (result && result.messageId) {
518
+ boundedSet(this._msgThreadMap, String(result.messageId), threadId, 500);
519
+ if (text) boundedSet(this._msgTextCache, String(result.messageId), text, 500);
520
+ }
521
+ return result;
522
+ }
523
+
524
+ // Always use the vendor's Noise WebSocket path for attachments.
525
+ // The isE2EEThreadId() check was removed because:
526
+ // 1. It frequently returns false for valid E2EE DM thread IDs (user-id format),
527
+ // which caused fallback to api.sendMessage → MQTT, where Facebook strips
528
+ // attachment_fbids from the E2EE envelope → attachment silently dropped.
529
+ // 2. This method is only called when the thread is KNOWN to be an E2EE DM
530
+ // (sendMessage.js routes here only when isSingleUser=true AND e2ee.isConnected()).
531
+ // 3. client.sendImage/sendVideo/sendAudio handle the JID conversion internally
532
+ // (100055943906136 → 100055943906136.0@msgr) so the threadId format is fine.
533
+
534
+ // E2EE path: read stream(s) → Buffer, detect type, send via vendor
535
+ const path = require("path");
536
+ let mime;
537
+ try { mime = require("mime"); } catch (_) {}
538
+
539
+ const list = Array.isArray(attachment) ? attachment : [attachment];
540
+ const results = [];
541
+
542
+ for (const stream of list) {
543
+ const data = await _streamToBuffer(stream, MAX_E2EE_ATTACHMENT_BYTES);
544
+ let fileName = (stream.path ? path.basename(String(stream.path)) : null);
545
+ let mimeType = fileName ? ((mime && mime.getType(fileName)) || _guessMime(fileName)) : null;
546
+
547
+ if (!fileName || !mimeType || mimeType === "application/octet-stream") {
548
+ const sniffed = _sniffMime(data);
549
+ if (sniffed) {
550
+ mimeType = sniffed.mimeType;
551
+ if (!fileName) fileName = `file.${sniffed.ext}`;
552
+ } else {
553
+ fileName = fileName || "file.bin";
554
+ mimeType = mimeType || "application/octet-stream";
555
+ }
556
+ } else if (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/")) {
557
+ // File extension can lie about actual content (e.g. a JPEG saved
558
+ // with a .png name) — verify against the real bytes and correct
559
+ // mimeType if they disagree, since that also feeds dimension parsing.
560
+ const sniffed = _sniffMime(data);
561
+ if (sniffed && sniffed.mimeType !== mimeType) {
562
+ logger.error("E2EE", `[media-mismatch] filename says ${mimeType} but content is actually ${sniffed.mimeType} (${fileName}) — using real content type`);
563
+ mimeType = sniffed.mimeType;
564
+ }
565
+ }
566
+
567
+ const dims = mimeType.startsWith("image/") ? _getImageDimensions(data, mimeType) : null;
568
+
569
+ console.log(`[E2EEBridge] sendMessage attachment (native engine): fileName=${fileName}, mimeType=${mimeType}, size=${data.length} bytes, dims=${dims ? dims.width + "x" + dims.height : "n/a"}, threadId=${threadId}`);
570
+
571
+ let mediaType;
572
+ if (mimeType.startsWith("image/")) mediaType = "image";
573
+ else if (mimeType.startsWith("video/")) mediaType = "video";
574
+ else if (mimeType.startsWith("audio/")) mediaType = "audio";
575
+ else mediaType = "document";
576
+
577
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
578
+ const isGroupAttachment = this._knownE2EEGroups.has(String(threadId));
579
+
580
+ const appState = this.api.getAppState();
581
+ let result;
582
+ try {
583
+ result = await nativeMediaBridge.sendMedia(appState, threadId, mediaType, data, mimeType, {
584
+ caption: text || undefined,
585
+ width: dims ? dims.width : undefined,
586
+ height: dims ? dims.height : undefined,
587
+ fileName,
588
+ replyToId: replyToMessageId,
589
+ isGroup: isGroupAttachment
590
+ });
591
+ } catch (nativeErr) {
592
+ if (isGroupAttachment) {
593
+ // Vendor's own group Sender-Key encryption is confirmed broken
594
+ // (see the native-group message listener above) — falling back
595
+ // to it here would just fail the same way, so don't bother.
596
+ logger.error("E2EE", "[native-media] group attachment send failed, no fallback available for groups: " +
597
+ (nativeErr && nativeErr.message ? nativeErr.message : String(nativeErr)));
598
+ throw nativeErr;
599
+ }
600
+ logger.error("E2EE", "[native-media] send failed, falling back to legacy vendor engine: " +
601
+ (nativeErr && nativeErr.message ? nativeErr.message : String(nativeErr)));
602
+ const input = { threadId, data, fileName, mimeType, caption: text || undefined, replyToMessageId,
603
+ width: dims ? dims.width : undefined, height: dims ? dims.height : undefined };
604
+ if (mediaType === "image") result = await this.client.sendImage(input);
605
+ else if (mediaType === "video") result = await this.client.sendVideo(input);
606
+ else if (mediaType === "audio") result = await this.client.sendAudio(input);
607
+ else result = await this.client.sendFile(input);
608
+ }
609
+ try {
610
+ console.log(`[E2EEBridge] send result:`, JSON.stringify(result, (k, v) => typeof v === "bigint" ? v.toString() : v));
611
+ } catch (_) { console.log(`[E2EEBridge] send result (non-serializable):`, result); }
612
+ if (result && result.messageId) {
613
+ boundedSet(this._msgThreadMap, String(result.messageId), threadId, 500);
614
+ // Cache our own sent media so replies to it (e.g. "/imgur" replying
615
+ // to a /pp response) can be resolved without a CDN round-trip —
616
+ // we already have the plaintext bytes right here.
617
+ this._mediaCache = this._mediaCache || new Map();
618
+ boundedSet(this._mediaCache, String(result.messageId), {
619
+ kind: mediaType,
620
+ localBuffer: data,
621
+ mimeType,
622
+ fileName,
623
+ width: dims ? dims.width : undefined,
624
+ height: dims ? dims.height : undefined
625
+ }, 50);
626
+ }
627
+ results.push(result);
628
+ }
629
+
630
+ return results.length === 1 ? results[0] : results;
631
+ }
632
+
633
+ /**
634
+ * Downloads + decrypts an incoming E2EE media attachment and returns a
635
+ * short-lived local HTTP URL for it (so existing GoatBot commands that
636
+ * do axios.get(attachment.url) work unmodified).
637
+ */
638
+ async resolveAttachment(meta) {
639
+ if (!meta) throw new Error("Invalid E2EE attachment metadata");
640
+
641
+ // Our own sent media — we already have the plaintext bytes.
642
+ if (meta.localBuffer) {
643
+ return localMediaServer.serveBuffer(meta.localBuffer, meta.mimeType);
644
+ }
645
+
646
+ if (!meta.directPath || !meta.mediaKey) throw new Error("Invalid E2EE attachment metadata");
647
+
648
+ // directPath from the message is host-relative (e.g. "/v/t800.../x.enc?...").
649
+ // The exact CDN host Messenger's own client resolves this against isn't
650
+ // captured anywhere in the decoded message — this default is a
651
+ // best-effort guess and can be overridden via env if it turns out wrong.
652
+ const host = process.env.FB_E2EE_MEDIA_DOWNLOAD_HOST || "rupload.facebook.com";
653
+ const fullUrl = meta.directPath.startsWith("http") ? meta.directPath : `https://${host}${meta.directPath}`;
654
+
655
+ const result = await this.client.downloadMedia({
656
+ directPath: fullUrl,
657
+ mediaKey: Buffer.from(meta.mediaKey).toString("base64"),
658
+ mediaSha256: meta.fileSHA256 ? Buffer.from(meta.fileSHA256).toString("base64") : undefined,
659
+ mediaEncSha256: meta.fileEncSHA256 ? Buffer.from(meta.fileEncSHA256).toString("base64") : undefined,
660
+ mediaType: meta.kind,
661
+ mimeType: meta.mimeType
662
+ });
663
+
664
+ const buffer = Buffer.isBuffer(result) ? result : (result && result.data ? result.data : null);
665
+ if (!buffer) throw new Error("downloadMedia returned no data");
666
+
667
+ return localMediaServer.serveBuffer(buffer, meta.mimeType);
668
+ }
669
+
670
+ isKnownE2EEGroup(threadId) {
671
+ return !!(this._knownE2EEGroups && this._knownE2EEGroups.has(String(threadId)));
672
+ }
673
+
674
+ getKnownThreads() {
675
+ return this._knownE2EEThreads ? Array.from(this._knownE2EEThreads) : [];
676
+ }
677
+
678
+ async markRead(threadId, watermarkTs) {
679
+ this.ensureConnected();
680
+ return nativeMediaBridge.markRead(this.api.getAppState(), threadId, watermarkTs);
681
+ }
682
+
683
+ getThreadIdForMessage(messageId) {
684
+ return (this._msgThreadMap && this._msgThreadMap.get(String(messageId))) || undefined;
685
+ }
686
+
687
+ getSenderJid(messageId) {
688
+ return (this._senderJidMap && this._senderJidMap.get(String(messageId))) || undefined;
689
+ }
690
+
691
+ async sendReaction(threadId, messageId, reaction, senderJid) {
692
+ this.ensureConnected();
693
+ if (!senderJid) senderJid = this.getSenderJid(messageId);
694
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
695
+ const isGroup = this._knownE2EEGroups.has(String(threadId));
696
+ try {
697
+ return await nativeMediaBridge.sendReaction(this.api.getAppState(), threadId, messageId, senderJid, reaction, isGroup);
698
+ } catch (err) {
699
+ if (isGroup) {
700
+ logger.error("E2EE", "[native-media] group reaction failed, no fallback available for groups: " + (err && err.message ? err.message : String(err)));
701
+ throw err;
702
+ }
703
+ logger.error("E2EE", "[native-media] reaction failed, falling back to legacy engine: " + (err && err.message ? err.message : String(err)));
704
+ return this.client.sendReaction({ threadId, messageId, reaction, senderJid });
705
+ }
706
+ }
707
+
708
+ async sendTyping(threadId, isTyping) {
709
+ this.ensureConnected();
710
+ return this.client.sendTyping({ threadId, isTyping: isTyping !== false });
711
+ }
712
+
713
+ async unsendMessage(messageId, threadId) {
714
+ this.ensureConnected();
715
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
716
+ const isGroup = this._knownE2EEGroups.has(String(threadId));
717
+ try {
718
+ return await nativeMediaBridge.unsendMessage(this.api.getAppState(), threadId, messageId, isGroup);
719
+ } catch (err) {
720
+ if (isGroup) {
721
+ logger.error("E2EE", "[native-media] group unsend failed, no fallback available for groups: " + (err && err.message ? err.message : String(err)));
722
+ throw err;
723
+ }
724
+ logger.error("E2EE", "[native-media] unsend failed, falling back to legacy engine: " + (err && err.message ? err.message : String(err)));
725
+ return this.client.unsendMessage({ messageId, threadId });
726
+ }
727
+ }
728
+
729
+ async editMessage(threadId, messageId, newText) {
730
+ this.ensureConnected();
731
+ this._knownE2EEGroups = this._knownE2EEGroups || new Set();
732
+ const isGroup = this._knownE2EEGroups.has(String(threadId));
733
+ if (isGroup) {
734
+ // Vendor's group Sender-Key encryption is broken, so route
735
+ // group edits through native (it doesn't need a chatJid — the
736
+ // messageId alone identifies which chat/message to edit).
737
+ try {
738
+ return await nativeMediaBridge.editMessage(this.api.getAppState(), messageId, newText);
739
+ } catch (err) {
740
+ logger.error("E2EE", "[native-media] group edit failed, no fallback available for groups: " + (err && err.message ? err.message : String(err)));
741
+ throw err;
742
+ }
743
+ }
744
+ return this.client.editMessage({ threadId, messageId, newText });
745
+ }
746
+
747
+ // ─────────────────────────────────────────────────────────────────────────
748
+ // Receive API
749
+ // ─────────────────────────────────────────────────────────────────────────
750
+
751
+ onMessage(callback) {
752
+ this._messageCallback = callback;
753
+ }
754
+
755
+ // ─────────────────────────────────────────────────────────────────────────
756
+ // Info / lifecycle
757
+ // ─────────────────────────────────────────────────────────────────────────
758
+
759
+ getPublicKeys() {
760
+ return {
761
+ info: "Keys managed by vendored E2EE engine (Signal Protocol / Noise handshake).",
762
+ note: "Identity + device keys are stored in the device-store file. Do NOT delete it.",
763
+ };
764
+ }
765
+
766
+ async disconnect() {
767
+ if (this.client) {
768
+ try { await this.client.disconnect(); } catch (_) {}
769
+ }
770
+ this.connected = false;
771
+ logger.info("E2EE", "E2EE disconnected.");
772
+ }
773
+ }
774
+
775
+ module.exports = { E2EEBridge };
776
+
777
+ // ─────────────────────────────────────────────────────────────────────────────
778
+ // Private helpers
779
+ // ─────────────────────────────────────────────────────────────────────────────
780
+
781
+ // E2EE media send goes through: full buffer in memory → Signal Protocol
782
+ // encryption (another same-size buffer) → base64-JSON serialization across
783
+ // the native FFI boundary (~33% larger again) → a native-side copy. A single
784
+ // large file can multiply to 3-4x its size in peak RAM within one send call,
785
+ // which was enough to OOM-kill the whole process on a memory-constrained
786
+ // container (observed: a 66MB video killed a bot with ~500MB-1GB available).
787
+ //
788
+ // 2026-08-20 fix: native/lib/index.mjs was actually serializing this Buffer
789
+ // via `Array.from(data)` (one boxed JS number per byte) instead of the
790
+ // intended base64 string — that's 4-8x larger than the buffer BEFORE
791
+ // JSON.stringify even runs on top of it, not the ~33% this comment always
792
+ // assumed. A 17.5MB video sent that way peaked heapTotal at ~568MB in under
793
+ // 2s and crashed the whole process (all threads, not just the sender) with
794
+ // "JavaScript heap out of memory". Fixed by switching every send*() in
795
+ // native/lib/index.mjs to `data.toString("base64")`, matching what
796
+ // setGroupPhoto()/downloadE2EEMedia() already did. Keep MAX_E2EE_ATTACHMENT_BYTES
797
+ // here as a hard ceiling regardless — 20MB should be safe now, but there's no
798
+ // reason to let it climb higher without also raising
799
+ // NODE_OPTIONS=--max-old-space-size on Railway.
800
+ const MAX_E2EE_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20MB hard ceiling — pair with NODE_OPTIONS=--max-old-space-size on Railway for extra headroom
801
+
802
+ function _streamToBuffer(stream, maxBytes) {
803
+ return new Promise(function (resolve, reject) {
804
+ var chunks = [];
805
+ var total = 0;
806
+ var aborted = false;
807
+ stream.on("data", function (c) {
808
+ if (aborted) return;
809
+ total += c.length;
810
+ if (maxBytes && total > maxBytes) {
811
+ aborted = true;
812
+ if (typeof stream.destroy === "function") stream.destroy();
813
+ reject(new Error(`Attachment too large (>${(maxBytes / 1024 / 1024).toFixed(0)}MB) — refusing to buffer it in memory for E2EE send to avoid crashing the process.`));
814
+ return;
815
+ }
816
+ chunks.push(c);
817
+ });
818
+ stream.on("end", function () { if (!aborted) resolve(Buffer.concat(chunks)); });
819
+ stream.on("error", function (err) { if (!aborted) reject(err); });
820
+ });
821
+ }
822
+
823
+ function _guessMime(fileName) {
824
+ var ext = (fileName || "").split(".").pop().toLowerCase();
825
+ var map = {
826
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
827
+ webp: "image/webp", mp4: "video/mp4", mov: "video/quicktime",
828
+ avi: "video/x-msvideo", mkv: "video/x-matroska",
829
+ mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav",
830
+ m4a: "audio/mp4", aac: "audio/aac", opus: "audio/ogg; codecs=opus",
831
+ pdf: "application/pdf", zip: "application/zip"
832
+ };
833
+ return map[ext] || "application/octet-stream";
834
+ }
835
+
836
+ // Detects file type from the actual downloaded bytes when no filename/extension
837
+ // is available (e.g. axios/http response streams used by commands like sing.js
838
+ // that download remote media — those streams have no .path, so the old code
839
+ // always fell back to fileName=file.bin / mimeType=application/octet-stream,
840
+ // which Messenger's E2EE client does not render as playable media/images).
841
+ function _sniffMime(buf) {
842
+ if (!buf || buf.length < 12) return null;
843
+
844
+ // MP3: 'ID3' tag, or frame sync (0xFF Ex/Fx)
845
+ if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return { mimeType: "audio/mpeg", ext: "mp3" };
846
+ if (buf[0] === 0xFF && (buf[1] & 0xE0) === 0xE0) return { mimeType: "audio/mpeg", ext: "mp3" };
847
+
848
+ // WAV: 'RIFF'....'WAVE'
849
+ if (buf.slice(0, 4).toString("ascii") === "RIFF" && buf.slice(8, 12).toString("ascii") === "WAVE")
850
+ return { mimeType: "audio/wav", ext: "wav" };
851
+
852
+ // OGG: 'OggS'
853
+ if (buf.slice(0, 4).toString("ascii") === "OggS") return { mimeType: "audio/ogg", ext: "ogg" };
854
+
855
+ // M4A/MP4 family: '....ftyp'
856
+ if (buf.slice(4, 8).toString("ascii") === "ftyp") {
857
+ var brand = buf.slice(8, 12).toString("ascii");
858
+ if (brand.indexOf("M4A") !== -1) return { mimeType: "audio/mp4", ext: "m4a" };
859
+
860
+ // Brand alone is unreliable for audio-only files (common brands like
861
+ // "isom"/"mp42" are used for both). Scan for 'hdlr' boxes and check
862
+ // the handler type ('vide' = has a video track, 'soun' = audio only).
863
+ var hasVideoTrack = false, hasAudioTrack = false;
864
+ var idx = 0;
865
+ while ((idx = buf.indexOf("hdlr", idx, "ascii")) !== -1) {
866
+ var handlerType = buf.slice(idx + 8, idx + 12).toString("ascii");
867
+ if (handlerType === "vide") hasVideoTrack = true;
868
+ if (handlerType === "soun") hasAudioTrack = true;
869
+ idx += 4;
870
+ }
871
+ if (hasVideoTrack) return { mimeType: "video/mp4", ext: "mp4" };
872
+ if (hasAudioTrack) return { mimeType: "audio/mp4", ext: "m4a" };
873
+ return { mimeType: "video/mp4", ext: "mp4" };
874
+ }
875
+
876
+ // PNG
877
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47)
878
+ return { mimeType: "image/png", ext: "png" };
879
+
880
+ // JPEG
881
+ if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return { mimeType: "image/jpeg", ext: "jpg" };
882
+
883
+ // GIF
884
+ if (buf.slice(0, 3).toString("ascii") === "GIF") return { mimeType: "image/gif", ext: "gif" };
885
+
886
+ // WEBP: 'RIFF'....'WEBP'
887
+ if (buf.slice(0, 4).toString("ascii") === "RIFF" && buf.slice(8, 12).toString("ascii") === "WEBP")
888
+ return { mimeType: "image/webp", ext: "webp" };
889
+
890
+ // PDF
891
+ if (buf.slice(0, 4).toString("ascii") === "%PDF") return { mimeType: "application/pdf", ext: "pdf" };
892
+
893
+ return null;
894
+ }
895
+
896
+ // Parses width/height from raw image bytes (no external deps).
897
+ // Needed because Messenger's E2EE ImageMessage/VideoMessage transport expects
898
+ // dimension hints, and the vendored library previously never populated them.
899
+ function _getImageDimensions(buf, mimeType) {
900
+ try {
901
+ if (mimeType === "image/png" && buf.length >= 24) {
902
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
903
+ }
904
+ if (mimeType === "image/gif" && buf.length >= 10) {
905
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
906
+ }
907
+ if (mimeType === "image/jpeg") {
908
+ let offset = 2;
909
+ while (offset < buf.length) {
910
+ if (buf[offset] !== 0xFF) break;
911
+ const marker = buf[offset + 1];
912
+ if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD9)) {
913
+ offset += 2;
914
+ continue;
915
+ }
916
+ const segLength = buf.readUInt16BE(offset + 2);
917
+ if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
918
+ return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
919
+ }
920
+ offset += 2 + segLength;
921
+ }
922
+ }
923
+ } catch (_) {}
924
+ return null;
925
+ }