@eryxenx/fca 1.0.4 → 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/README.md +70 -63
- package/module/config.js +4 -1
- package/module/loginHelper.js +5 -2
- package/package.json +1 -1
- package/src/api/messaging/markAsDelivered.js +12 -0
- package/src/api/messaging/markAsRead.js +33 -11
- package/src/api/socket/core/connectMqtt.js +9 -2
- package/src/api/socket/core/getSeqID.js +50 -40
- package/src/api/socket/e2ee/index.js +81 -19
- package/src/api/socket/e2ee/native/lib/index.mjs +43 -15
- package/src/api/socket/e2ee/native/nativeMediaBridge.js +40 -0
- package/src/api/socket/e2ee/vendor/fme/dist/index.cjs +5 -5
- package/src/api/socket/listenMqtt.js +57 -20
- package/src/utils/headers.js +45 -10
package/README.md
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# 💬 @eryxenx/fca
|
|
4
4
|
|
|
5
5
|
**Unofficial Facebook Messenger Bot API for Node.js**
|
|
6
|
-
NEXCA MQTT
|
|
6
|
+
NEXCA MQTT · Signal Protocol E2EE (mautrix-go powered) · sessionGuard · 90+ API Methods · Zero TypeScript
|
|
7
7
|
|
|
8
8
|
[](https://www.npmjs.com/package/@eryxenx/fca)
|
|
9
9
|
[](./LICENSE-MIT)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
|
|
12
|
-
[Features](#-features)
|
|
12
|
+
[Features](#-features) • [Installation](#-installation) • [Quick Start](#-quick-start) • [E2EE](#-e2ee--encrypted-conversations) • [sessionGuard](#-sessionguard) • [sendBroadcast](#-sendbroadcast) • [API Reference](#-api-reference)
|
|
13
13
|
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## âš¡ Why @eryxenx/fca?
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
20
|
+
- ✅ **NEXCA MQTT** — stable connection core, autoReconnect, jitter
|
|
21
|
+
- ✅ **Signal Protocol E2EE** — Facebook real encrypted conversations support. Reliable media/reaction/unsend delivery is powered by a bundled **mautrix-go / mautrix-meta** native engine (© Tulir Asokan, MPL-2.0) — NEXCA's own hand-written E2EE encoder produced protocol-valid messages that Facebook accepted but Messenger clients failed to render for attachments/reactions/unsend, so this fork routes those through the native engine instead. Text messaging still uses the original engine (already reliable). The native engine is opt-in (`FCA_E2EE_NATIVE=1`) — see [Native media engine](#native-media-engine).
|
|
22
|
+
- ✅ **sessionGuard** — appstate corruption and silent logout protection, auto-backup
|
|
23
|
+
- ✅ **sendBroadcast** — rate-limited multi-thread broadcast
|
|
24
|
+
- ✅ **Fixed MQTT subscribe race condition** — no more "Connection refused: No subscription existed"
|
|
25
|
+
- ✅ **`isActiveClient()` guard** — stale MQTT client events no longer processed
|
|
26
|
+
- ✅ **`connectTimeout` extended** — no premature logout on slow networks
|
|
27
|
+
- ✅ **autoReconnect** — auto-reconnect on connection drop
|
|
28
|
+
- ✅ **GoatBot compatible** — all API signatures unchanged (`threadID` optional etc.)
|
|
29
|
+
- ✅ **90+ API methods** — sendMessage, editMessage, setMessageReaction, getThreadInfo and more
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## ✨ Features
|
|
34
34
|
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
35
|
+
- ✅ Full Messenger API — messages, reactions, attachments, stickers, polls, pins
|
|
36
|
+
- ✅ NEXCA MQTT — stable connection, autoReconnect, jitter, isActiveClient guard
|
|
37
|
+
- ✅ E2EE — Signal Protocol encrypted threads, auto-connects after login (listenE2EE, e2ee.*)
|
|
38
|
+
- ✅ E2EE media engine — native mautrix-go engine (opt-in via `FCA_E2EE_NATIVE=1`) for reliable image/video/audio/document sending, reactions, and unsend in encrypted threads (falls back to the built-in JS engine automatically)
|
|
39
|
+
- ✅ E2EE incoming media — attachments on encrypted messages (including replies) are auto-decrypted and served locally, so existing commands work unmodified
|
|
40
|
+
- ✅ sessionGuard — appstate auto-save, corruption guard, .bak backup
|
|
41
|
+
- ✅ sendBroadcast — parallel/sequential multi-thread sending with rate limit
|
|
42
|
+
- ✅ MessengerBot — Discord.js/Telegraf style (.command, .hears, .launch)
|
|
43
|
+
- ✅ createFcaClient — namespaced facade (client.messages, client.threads etc.)
|
|
44
|
+
- ✅ GoatBot / Mirai compatible — drop-in replacement
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## 📦 Installation
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
npm install @eryxenx/fca
|
|
@@ -53,11 +53,13 @@ npm install @eryxenx/fca
|
|
|
53
53
|
|
|
54
54
|
Node.js >= 18 required.
|
|
55
55
|
|
|
56
|
-
The E2EE media engine ships a precompiled native binary (`src/api/socket/e2ee/native/build/`) for **Linux (.so)** and **Windows (.dll)**, loaded via the `koffi` FFI. `npm install` pulls in `koffi` and `yumi-json-bigint` automatically
|
|
56
|
+
The E2EE media engine ships a precompiled native binary (`src/api/socket/e2ee/native/build/`) for **Linux (.so)** and **Windows (.dll)**, loaded via the `koffi` FFI. `npm install` pulls in `koffi` and `yumi-json-bigint` automatically — no extra setup needed. Note the native engine only runs when `FCA_E2EE_NATIVE=1` is set (see [Native media engine](#native-media-engine)); on platforms other than Linux/Windows x64 (e.g. macOS, ARM) the binary fails to load and E2EE media/reactions/unsend automatically fall back to the built-in JS engine (text messaging is unaffected either way).
|
|
57
|
+
|
|
58
|
+
The E2EE socket's User-Agent defaults to desktop Windows Chrome 139 to match the rest of the client; override with `FCA_E2EE_UA` if you run with a different UA.
|
|
57
59
|
|
|
58
60
|
---
|
|
59
61
|
|
|
60
|
-
##
|
|
62
|
+
## 🚀 Quick Start
|
|
61
63
|
|
|
62
64
|
### Classic (GoatBot compatible)
|
|
63
65
|
|
|
@@ -81,14 +83,14 @@ login({ appState: require("./account.json") }, { listenEvents: true }, (err, api
|
|
|
81
83
|
|
|
82
84
|
### With E2EE (regular + encrypted threads)
|
|
83
85
|
|
|
84
|
-
`connectE2EE()` runs **automatically** right after login
|
|
86
|
+
`connectE2EE()` runs **automatically** right after login — you never call it yourself. Calling it again in your own code opens a second, racing connection on the same bridge. Just check `api.e2ee.isConnected()` if you need to know the status.
|
|
85
87
|
|
|
86
88
|
```javascript
|
|
87
89
|
login({ appState: require("./account.json") }, { listenEvents: true }, (err, api) => {
|
|
88
90
|
if (err) throw err;
|
|
89
91
|
|
|
90
92
|
api.sessionGuard("./account.json");
|
|
91
|
-
// E2EE is already connecting in the background here
|
|
93
|
+
// E2EE is already connecting in the background here — do not call connectE2EE().
|
|
92
94
|
|
|
93
95
|
api.listenE2EE((err, event) => {
|
|
94
96
|
if (err) throw err;
|
|
@@ -116,35 +118,35 @@ login({ appState }, options, (err, api) => {
|
|
|
116
118
|
debounce: 30 * 1000
|
|
117
119
|
});
|
|
118
120
|
|
|
119
|
-
// Nothing else needed for E2EE
|
|
121
|
+
// Nothing else needed for E2EE — it auto-connects inside @eryxenx/fca
|
|
120
122
|
// right after login. Do NOT call api.connectE2EE() here.
|
|
121
123
|
|
|
122
124
|
api.listenMqtt(callback);
|
|
123
125
|
});
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
**This is the only setup needed
|
|
128
|
+
**This is the only setup needed — nothing else in `login.js` (or anywhere in GoatBot) has to change for E2EE.** Once login succeeds, `api.e2ee` is already connecting in the background, and every existing GoatBot call — `api.sendMessage(...)` (with or without `attachment`), `api.setMessageReaction(...)`, `api.unsendMessage(...)` — automatically detects when a thread is an encrypted DM and routes through the E2EE engine internally. Commands don't need to know or care whether a thread is encrypted; they call the same functions either way.
|
|
127
129
|
|
|
128
130
|
#### Adding this to your own login.js
|
|
129
131
|
|
|
130
|
-
Your bot's actual `login.js` will look different from the snippet above (dashboard setup, database sync, custom logging, etc.)
|
|
132
|
+
Your bot's actual `login.js` will look different from the snippet above (dashboard setup, database sync, custom logging, etc.) — you're not replacing the file, just adding/checking two things inside your existing login callback:
|
|
131
133
|
|
|
132
134
|
| Step | What to do |
|
|
133
135
|
|---|---|
|
|
134
136
|
| 1. sessionGuard | Add `api.sessionGuard(accountPath, { interval, debounce })` once, right after login succeeds, if it's not already there. |
|
|
135
|
-
| 2. E2EE | Nothing to add
|
|
137
|
+
| 2. E2EE | Nothing to add — it's automatic. If your `login.js` already has an explicit `await api.connectE2EE()` call, **delete it** — it's redundant with the automatic connection and the two can race. |
|
|
136
138
|
|
|
137
|
-
To check whether E2EE is already wired into your bot, search for `e2ee.isConnected` in your `login.js`
|
|
139
|
+
To check whether E2EE is already wired into your bot, search for `e2ee.isConnected` in your `login.js` — if it's there, setup is already done.
|
|
138
140
|
|
|
139
141
|
---
|
|
140
142
|
|
|
141
|
-
##
|
|
143
|
+
## 🔠E2EE — Encrypted Conversations
|
|
142
144
|
|
|
143
|
-
Uses Facebook's real Signal Protocol infrastructure
|
|
145
|
+
Uses Facebook's real Signal Protocol infrastructure — same as the official Messenger app.
|
|
144
146
|
|
|
145
147
|
### Setup
|
|
146
148
|
|
|
147
|
-
E2EE connects automatically after login (creating `.nexca/e2ee_device.json` on first run)
|
|
149
|
+
E2EE connects automatically after login (creating `.nexca/e2ee_device.json` on first run) — there's nothing to call manually. Just check status once connected:
|
|
148
150
|
|
|
149
151
|
```javascript
|
|
150
152
|
console.log(api.e2ee.isConnected()); // true (may need a moment right after login)
|
|
@@ -169,7 +171,7 @@ api.listenE2EE((err, event) => {
|
|
|
169
171
|
```javascript
|
|
170
172
|
await api.e2ee.sendMessage(threadID, "Hello!");
|
|
171
173
|
await api.e2ee.sendMessage(threadID, { body: "Photo!", attachment: fs.createReadStream("photo.jpg") });
|
|
172
|
-
await api.e2ee.sendReaction(threadID, messageID, "
|
|
174
|
+
await api.e2ee.sendReaction(threadID, messageID, "â¤ï¸");
|
|
173
175
|
await api.e2ee.sendTyping(threadID, true);
|
|
174
176
|
await api.e2ee.unsendMessage(messageID, threadID);
|
|
175
177
|
await api.e2ee.editMessage(threadID, messageID, "Updated!");
|
|
@@ -179,15 +181,20 @@ await api.e2ee.disconnect();
|
|
|
179
181
|
|
|
180
182
|
### Native media engine
|
|
181
183
|
|
|
182
|
-
Sending image/video/audio/document attachments, reactions, and unsend in E2EE threads
|
|
184
|
+
Sending image/video/audio/document attachments, reactions, and unsend in E2EE threads can be routed through a precompiled **mautrix-go / mautrix-meta** binary (`src/api/socket/e2ee/native/`) via the `koffi` FFI, instead of this project's own hand-written Signal Protocol encoder. This project's own encoder builds protocol-valid messages that Facebook's servers accept, but real Messenger clients silently fail to render — the native engine avoids that entirely.
|
|
185
|
+
|
|
186
|
+
**âš ï¸ The native engine is DISABLED by default.** Both the vendored JS engine and the native engine register with Facebook as the *same* E2EE device (same device id), and Facebook's server keeps only one live E2EE socket per device — so when both run at once they kick each other off in a loop (a fresh handshake every few seconds, which looks like automated behavior to Facebook). The vendored JS engine is therefore given exclusive use of the socket unless you opt in:
|
|
187
|
+
|
|
188
|
+
- Set `FCA_E2EE_NATIVE=1` in the environment to enable the native engine. With it on you get E2EE group text receive/send, native media sends, native reactions/unsend/edit/markRead — but you also accept the device-kick socket churn described above (the JS engine reconnects with exponential backoff, so it stays usable, just noisy).
|
|
189
|
+
- With it off (default): E2EE DM text + DM media work through the vendored engine. E2EE **group** send/receive, and E2EE markRead, are unavailable; reactions/unsend/edit in E2EE DMs fall back to the vendored engine.
|
|
183
190
|
|
|
184
191
|
- Text messages always use @eryxenx/fca's own E2EE engine (already reliable, no native dependency).
|
|
185
192
|
- If the native engine fails to load or a send fails (unsupported platform, binary mismatch, etc.), @eryxenx/fca automatically falls back to its own engine and logs `[native-media] ... falling back to legacy vendor engine`.
|
|
186
|
-
- See `src/api/socket/e2ee/native/NOTICE.md` for the required MPL-2.0 attribution to the upstream mautrix-go/mautrix-meta project (
|
|
193
|
+
- See `src/api/socket/e2ee/native/NOTICE.md` for the required MPL-2.0 attribution to the upstream mautrix-go/mautrix-meta project (© Tulir Asokan and contributors) — do not remove it if you redistribute this fork.
|
|
187
194
|
|
|
188
195
|
### Receiving E2EE media (attachments & replies)
|
|
189
196
|
|
|
190
|
-
Incoming encrypted image/video/audio/document messages are automatically decrypted and served over a short-lived local URL (`http://127.0.0.1:<port>/<token>`, 15 min TTL), and populated into `event.attachments` / `event.messageReply.attachments`
|
|
197
|
+
Incoming encrypted image/video/audio/document messages are automatically decrypted and served over a short-lived local URL (`http://127.0.0.1:<port>/<token>`, 15 min TTL), and populated into `event.attachments` / `event.messageReply.attachments` — same shape as normal (non-E2EE) attachments. Reply-based commands (e.g. "reply to an image with `/imgur`") work the same in E2EE threads as in normal ones, no command code changes needed.
|
|
191
198
|
|
|
192
199
|
```javascript
|
|
193
200
|
if (event.messageReply && event.messageReply.attachments.length) {
|
|
@@ -195,11 +202,11 @@ if (event.messageReply && event.messageReply.attachments.length) {
|
|
|
195
202
|
}
|
|
196
203
|
```
|
|
197
204
|
|
|
198
|
-
If media resolution fails, check the console for `[media-resolve]` / `[media-decode]` errors
|
|
205
|
+
If media resolution fails, check the console for `[media-resolve]` / `[media-decode]` errors — this usually means the CDN download host (`FB_E2EE_MEDIA_DOWNLOAD_HOST` env var, default `rupload.facebook.com`) needs adjusting for your account/region.
|
|
199
206
|
|
|
200
207
|
---
|
|
201
208
|
|
|
202
|
-
##
|
|
209
|
+
## ðŸ›¡ï¸ sessionGuard
|
|
203
210
|
|
|
204
211
|
Protects your appstate from corruption and silent logouts.
|
|
205
212
|
|
|
@@ -216,8 +223,8 @@ api.sessionGuard("./account.json", {
|
|
|
216
223
|
What it does:
|
|
217
224
|
- Auto-saves appstate every N minutes
|
|
218
225
|
- Saves after every successful sendMessage (debounced)
|
|
219
|
-
- Corruption guard
|
|
220
|
-
- Auto-backup
|
|
226
|
+
- Corruption guard — never overwrites a larger appstate with a smaller one
|
|
227
|
+
- Auto-backup — writes `.bak` before every overwrite
|
|
221
228
|
|
|
222
229
|
```javascript
|
|
223
230
|
api.saveSession(); // force save now
|
|
@@ -227,7 +234,7 @@ api.stopSessionGuard(); // stop the timer
|
|
|
227
234
|
|
|
228
235
|
---
|
|
229
236
|
|
|
230
|
-
##
|
|
237
|
+
## 📡 sendBroadcast
|
|
231
238
|
|
|
232
239
|
Rate-limited multi-thread broadcast.
|
|
233
240
|
|
|
@@ -248,7 +255,7 @@ console.log(result.sent.length + "/" + result.total + " delivered");
|
|
|
248
255
|
|
|
249
256
|
---
|
|
250
257
|
|
|
251
|
-
##
|
|
258
|
+
## 🤖 MessengerBot
|
|
252
259
|
|
|
253
260
|
Discord.js/Telegraf style high-level bot class.
|
|
254
261
|
|
|
@@ -260,8 +267,8 @@ const bot = await createMessengerBot(
|
|
|
260
267
|
{ commandPrefix: "/", stopOnSignals: true }
|
|
261
268
|
);
|
|
262
269
|
|
|
263
|
-
bot.command("ping", async ctx => await ctx.replyAsync("pong
|
|
264
|
-
bot.hears(/hello/i, async ctx => await ctx.replyAsync("Hi!
|
|
270
|
+
bot.command("ping", async ctx => await ctx.replyAsync("pong ðŸ“"));
|
|
271
|
+
bot.hears(/hello/i, async ctx => await ctx.replyAsync("Hi! 👋"));
|
|
265
272
|
bot.on("messageCreate", event => console.log(event.body));
|
|
266
273
|
|
|
267
274
|
await bot.launch({ stopOnSignals: true });
|
|
@@ -269,7 +276,7 @@ await bot.launch({ stopOnSignals: true });
|
|
|
269
276
|
|
|
270
277
|
---
|
|
271
278
|
|
|
272
|
-
##
|
|
279
|
+
## 🎯 createFcaClient
|
|
273
280
|
|
|
274
281
|
Namespaced facade grouping all API methods by domain.
|
|
275
282
|
|
|
@@ -278,7 +285,7 @@ const { createFcaClient } = require("@eryxenx/fca");
|
|
|
278
285
|
const client = createFcaClient(api);
|
|
279
286
|
|
|
280
287
|
await client.messages.send("Hello!", threadID);
|
|
281
|
-
await client.messages.react("
|
|
288
|
+
await client.messages.react("â¤ï¸", messageID, threadID);
|
|
282
289
|
await client.threads.getInfo(threadID);
|
|
283
290
|
await client.users.getInfo(userID);
|
|
284
291
|
await client.account.refreshDtsg();
|
|
@@ -286,7 +293,7 @@ await client.account.refreshDtsg();
|
|
|
286
293
|
|
|
287
294
|
---
|
|
288
295
|
|
|
289
|
-
##
|
|
296
|
+
## 📖 API Reference
|
|
290
297
|
|
|
291
298
|
### Sending Messages
|
|
292
299
|
|
|
@@ -313,7 +320,7 @@ api.shareContact("Meet my friend!", userID, threadID);
|
|
|
313
320
|
api.editMessage("Updated text", messageID);
|
|
314
321
|
api.unsendMessage(messageID);
|
|
315
322
|
api.deleteMessage([messageID]);
|
|
316
|
-
api.setMessageReaction("
|
|
323
|
+
api.setMessageReaction("ðŸ˜", messageID, threadID); // threadID optional
|
|
317
324
|
api.setMessageReaction("", messageID); // remove reaction
|
|
318
325
|
api.getMessage(threadID, messageID);
|
|
319
326
|
api.forwardAttachment(attachmentID, [userID]);
|
|
@@ -349,7 +356,7 @@ api.searchForThread("query");
|
|
|
349
356
|
```javascript
|
|
350
357
|
api.setTitle("New Name", threadID);
|
|
351
358
|
api.changeThreadColor("#0084FF", threadID);
|
|
352
|
-
api.changeThreadEmoji("
|
|
359
|
+
api.changeThreadEmoji("🔥", threadID);
|
|
353
360
|
api.changeNickname("The Boss", threadID, userID);
|
|
354
361
|
api.changeGroupImage(fs.createReadStream("group.jpg"), threadID);
|
|
355
362
|
api.changeAdminStatus(threadID, userID, true);
|
|
@@ -413,7 +420,7 @@ api.uploadImageToImgbb(imageUrl);
|
|
|
413
420
|
|
|
414
421
|
---
|
|
415
422
|
|
|
416
|
-
##
|
|
423
|
+
## 📋 Login Options
|
|
417
424
|
|
|
418
425
|
| Option | Type | Default | Description |
|
|
419
426
|
|--------|------|---------|-------------|
|
|
@@ -426,23 +433,23 @@ api.uploadImageToImgbb(imageUrl);
|
|
|
426
433
|
| `autoReconnect` | boolean | true | Auto-reconnect MQTT on disconnect |
|
|
427
434
|
| `online` | boolean | false | Appear as online to others |
|
|
428
435
|
| `emitReady` | boolean | false | Emit ready event when MQTT connected |
|
|
429
|
-
| `proxy` | string |
|
|
436
|
+
| `proxy` | string | — | HTTP proxy URL |
|
|
430
437
|
| `userAgent` | string | Safari UA | Override HTTP User-Agent |
|
|
431
438
|
|
|
432
439
|
---
|
|
433
440
|
|
|
434
|
-
##
|
|
441
|
+
## 📄 License
|
|
435
442
|
|
|
436
443
|
MIT License
|
|
437
444
|
|
|
438
445
|
**@eryxenx/fca** by [EryXenX (Mohammad Akash)](https://github.com/EryXenX)
|
|
439
446
|
|
|
440
447
|
> The E2EE native media engine (`src/api/socket/e2ee/native/`) bundles a
|
|
441
|
-
> precompiled binary built from **mautrix-go** / **mautrix-meta**,
|
|
448
|
+
> precompiled binary built from **mautrix-go** / **mautrix-meta**, © Tulir
|
|
442
449
|
> Asokan and contributors, licensed under **MPL-2.0** (not MIT). See
|
|
443
|
-
> `src/api/socket/e2ee/native/NOTICE.md` for full attribution
|
|
450
|
+
> `src/api/socket/e2ee/native/NOTICE.md` for full attribution — this notice
|
|
444
451
|
> must be preserved in any redistribution of this fork.
|
|
445
|
-
NEXCA MQTT core by [Deku](https://github.com/dekuzxc)
|
|
452
|
+
NEXCA MQTT core by [Deku](https://github.com/dekuzxc) — MIT License
|
|
446
453
|
|
|
447
454
|
> Unauthorized copying or redistribution without credit is prohibited.
|
|
448
455
|
|
|
@@ -450,6 +457,6 @@ NEXCA MQTT core by [Deku](https://github.com/dekuzxc) — MIT License
|
|
|
450
457
|
|
|
451
458
|
<div align="center">
|
|
452
459
|
|
|
453
|
-
Made with
|
|
460
|
+
Made with â¤ï¸ by EryXenX
|
|
454
461
|
|
|
455
|
-
</div>
|
|
462
|
+
</div>
|
package/module/config.js
CHANGED
|
@@ -4,7 +4,10 @@ const logger = require("../func/logger");
|
|
|
4
4
|
const defaultConfig = {
|
|
5
5
|
autoUpdate: true,
|
|
6
6
|
mqtt: { enabled: true, reconnectInterval: 3600 },
|
|
7
|
-
|
|
7
|
+
// Opt-in only: auto re-login with credentials (via the third-party API)
|
|
8
|
+
// after a checkpoint/session failure is itself a strong suspicious-login
|
|
9
|
+
// signal and invalidates every other live session on the account.
|
|
10
|
+
autoLogin: false,
|
|
8
11
|
apiServer: "https://minhdong.site",
|
|
9
12
|
apiKey: "",
|
|
10
13
|
credentials: { email: "", password: "", twofactor: "" },
|
package/module/loginHelper.js
CHANGED
|
@@ -1283,14 +1283,17 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
|
|
|
1283
1283
|
api._listenMqttRaw = api.listenMqtt;
|
|
1284
1284
|
api.listen = api.listenMqtt;
|
|
1285
1285
|
}
|
|
1286
|
-
if (api.refreshFb_dtsg) {
|
|
1287
|
-
setInterval(function () {
|
|
1286
|
+
if (api.refreshFb_dtsg && !ctxMain._dtsgRefreshInterval) {
|
|
1287
|
+
ctxMain._dtsgRefreshInterval = setInterval(function () {
|
|
1288
1288
|
api.refreshFb_dtsg().then(function () {
|
|
1289
1289
|
logger("Successfully refreshed fb_dtsg");
|
|
1290
1290
|
}).catch(function () {
|
|
1291
1291
|
logger("An error occurred while refreshing fb_dtsg", "error");
|
|
1292
1292
|
});
|
|
1293
1293
|
}, 86400000);
|
|
1294
|
+
// Don't stack a new interval per login, and don't keep the process
|
|
1295
|
+
// alive just for this.
|
|
1296
|
+
if (ctxMain._dtsgRefreshInterval.unref) ctxMain._dtsgRefreshInterval.unref();
|
|
1294
1297
|
}
|
|
1295
1298
|
// ✅ nexca core features register
|
|
1296
1299
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/fca",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — send messages, media, reactions & more in encrypted chats, hassle-free",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -24,6 +24,18 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
24
24
|
return callback("Error: messageID or threadID is not defined");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Modern Messenger clients do not POST delivery receipts to the
|
|
28
|
+
// Mercury-era delivery_receipts.php endpoint — delivery is implicit in
|
|
29
|
+
// the MQTT sync channel. Calling that endpoint once per incoming message
|
|
30
|
+
// (which GoatBot's handlerEvents does) produced a constant stream of
|
|
31
|
+
// request shapes no real client emits. When MQTT is live we resolve
|
|
32
|
+
// immediately; the legacy HTTP call is kept only as a fallback for
|
|
33
|
+
// callers that explicitly use this API without an active listener.
|
|
34
|
+
if (ctx.mqttClient && ctx.mqttClient.connected) {
|
|
35
|
+
callback();
|
|
36
|
+
return returnPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const form = {};
|
|
28
40
|
|
|
29
41
|
form["message_ids[0]"] = messageID;
|
|
@@ -60,18 +60,38 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
60
60
|
return null;
|
|
61
61
|
} else {
|
|
62
62
|
try {
|
|
63
|
-
if (ctx.mqttClient) {
|
|
63
|
+
if (ctx.mqttClient && ctx.mqttClient.connected) {
|
|
64
|
+
// Modern clients send read state as a label-21 ls_req task (the
|
|
65
|
+
// same shape sendMessage.js already uses for its read watermark),
|
|
66
|
+
// not the legacy /mark_thread topic or the Mercury-era
|
|
67
|
+
// change_read_status.php endpoint.
|
|
68
|
+
const requestId = Math.floor(100 + Math.random() * 900);
|
|
69
|
+
const epoch = (BigInt(Date.now()) << 22n).toString();
|
|
70
|
+
const content = {
|
|
71
|
+
app_id: "2220391788200892",
|
|
72
|
+
payload: JSON.stringify({
|
|
73
|
+
tasks: [
|
|
74
|
+
{
|
|
75
|
+
label: "21",
|
|
76
|
+
payload: JSON.stringify({
|
|
77
|
+
thread_id: String(threadID),
|
|
78
|
+
last_read_watermark_ts: Date.now(),
|
|
79
|
+
sync_group: 1
|
|
80
|
+
}),
|
|
81
|
+
queue_name: String(threadID),
|
|
82
|
+
task_id: Math.floor(100 + Math.random() * 900),
|
|
83
|
+
failure_count: null
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
epoch_id: epoch,
|
|
87
|
+
version_id: "24804310205905615",
|
|
88
|
+
data_trace_id: "#" + Buffer.from(String(Math.random())).toString("base64").replace(/=+$/, "")
|
|
89
|
+
}),
|
|
90
|
+
request_id: requestId,
|
|
91
|
+
type: 3
|
|
92
|
+
};
|
|
64
93
|
const err = await new Promise(r =>
|
|
65
|
-
ctx.mqttClient.publish(
|
|
66
|
-
"/mark_thread",
|
|
67
|
-
JSON.stringify({
|
|
68
|
-
threadID,
|
|
69
|
-
mark: "read",
|
|
70
|
-
state: read
|
|
71
|
-
}),
|
|
72
|
-
{ qos: 1, retain: false },
|
|
73
|
-
r
|
|
74
|
-
)
|
|
94
|
+
ctx.mqttClient.publish("/ls_req", JSON.stringify(content), { qos: 1, retain: false }, r)
|
|
75
95
|
);
|
|
76
96
|
if (err) throw err;
|
|
77
97
|
} else {
|
|
@@ -83,6 +103,8 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
83
103
|
callback(e);
|
|
84
104
|
return e;
|
|
85
105
|
}
|
|
106
|
+
callback();
|
|
107
|
+
return null;
|
|
86
108
|
}
|
|
87
109
|
};
|
|
88
110
|
};
|
|
@@ -38,7 +38,10 @@ module.exports = function createListenMqtt(deps) {
|
|
|
38
38
|
return /No subscription existed|client disconnecting|socket hang up|ECONNRESET/i.test(msg || "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// Default to online unless explicitly disabled — GoatBot's optionsFca
|
|
42
|
+
// has no `online` key, which used to make this undefined (falsy), so the
|
|
43
|
+
// account sat permanently "inactive" while actively sending messages.
|
|
44
|
+
const chatOn = ctx.globalOptions.online !== false;
|
|
42
45
|
const sessionID = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) + 1;
|
|
43
46
|
const username = {
|
|
44
47
|
u: ctx.userID, s: sessionID, chat_on: chatOn, fg: false, d: ctx.clientId,
|
|
@@ -71,7 +74,10 @@ module.exports = function createListenMqtt(deps) {
|
|
|
71
74
|
Upgrade: "websocket",
|
|
72
75
|
"Sec-WebSocket-Version": "13",
|
|
73
76
|
"Accept-Encoding": "gzip, deflate, br",
|
|
74
|
-
|
|
77
|
+
// Must match the HTTP layer's Accept-Language (src/utils/headers.js)
|
|
78
|
+
// — a hardcoded different value here made the WS handshake and the
|
|
79
|
+
// HTTP requests disagree about the same "browser".
|
|
80
|
+
"Accept-Language": ctx.globalOptions.acceptLanguage || "en-US,en;q=0.9,vi;q=0.8",
|
|
75
81
|
"Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits"
|
|
76
82
|
},
|
|
77
83
|
origin: "https://www.facebook.com",
|
|
@@ -187,6 +193,7 @@ module.exports = function createListenMqtt(deps) {
|
|
|
187
193
|
|
|
188
194
|
mqttClient.on("message", function (topic, message) {
|
|
189
195
|
if (ctx._ending) return; // Ignore messages if ending
|
|
196
|
+
ctx.lastMqttActivity = Date.now(); // packet-level liveness for the heartbeat
|
|
190
197
|
try {
|
|
191
198
|
let jsonMessage = Buffer.isBuffer(message) ? Buffer.from(message).toString() : message;
|
|
192
199
|
try {
|
|
@@ -48,7 +48,10 @@ async function tryAutoLogin(logger, config, ctx, defaultFuncs) {
|
|
|
48
48
|
const password = config.credentials?.password || config.password;
|
|
49
49
|
const twofactor = config.credentials?.twofactor || config.twofactor || null;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Opt-in, consistent with loginHelper: auto re-login only runs when the
|
|
52
|
+
// user explicitly sets autoLogin: true. (Previously `!== false`, so it ran
|
|
53
|
+
// for everyone who never set the flag.)
|
|
54
|
+
if ((config.autoLogin !== true && config.autoLogin !== "true") || !email || !password) {
|
|
52
55
|
return null;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -210,7 +213,10 @@ async function tryAutoLogin(logger, config, ctx, defaultFuncs) {
|
|
|
210
213
|
logger(`getSeqID: web session refresh failed - ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
211
214
|
}
|
|
212
215
|
|
|
213
|
-
|
|
216
|
+
// NOTE: there was a `cookies` ReferenceError here (no such variable
|
|
217
|
+
// in scope) which made every auto-login attempt throw into the catch
|
|
218
|
+
// below and get reported as failed even when it succeeded.
|
|
219
|
+
return { ...result };
|
|
214
220
|
}
|
|
215
221
|
}
|
|
216
222
|
|
|
@@ -226,7 +232,6 @@ module.exports = function createGetSeqID(deps) {
|
|
|
226
232
|
const { listenMqtt, logger, emitAuth } = deps;
|
|
227
233
|
|
|
228
234
|
return function getSeqID(defaultFuncs, api, ctx, globalCallback, form, retryCount = 0) {
|
|
229
|
-
const MAX_RETRIES = 3;
|
|
230
235
|
const RETRY_DELAY = 2000;
|
|
231
236
|
ctx.t_mqttCalled = false;
|
|
232
237
|
|
|
@@ -267,55 +272,60 @@ module.exports = function createGetSeqID(deps) {
|
|
|
267
272
|
const detail = (err && err.detail && err.detail.message) ? ` | detail=${err.detail.message}` : "";
|
|
268
273
|
const msg = ((err && err.error) || (err && err.message) || String(err || "")) + detail;
|
|
269
274
|
|
|
275
|
+
// Definitive server-side session invalidation: error 1357001
|
|
276
|
+
// ("Log in to continue") or an explicit login block. The session is
|
|
277
|
+
// dead, not desynced — retrying within seconds (and especially
|
|
278
|
+
// re-logging-in with credentials) just hammers Facebook with
|
|
279
|
+
// failed-auth requests from an already-flagged account and makes the
|
|
280
|
+
// account-level risk score worse. Stop cleanly instead and surface
|
|
281
|
+
// account_inactive so the operator can step in manually.
|
|
282
|
+
const isDefinitive =
|
|
283
|
+
/1357001|blocked the login|checkpoint\/block/i.test(msg) ||
|
|
284
|
+
(err && err.res && err.res.error === 1357001);
|
|
285
|
+
if (isDefinitive) {
|
|
286
|
+
logger(`getSeqID: session definitively invalidated (${msg}) — NOT retrying. Update appstate manually.`, "error");
|
|
287
|
+
return emitAuth(ctx, api, globalCallback, /blocked/i.test(msg) ? "login_blocked" : "not_logged_in", msg);
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
// Check if this is an auth-related error
|
|
271
|
-
const isAuthError = /Not logged in|no sync_sequence_id found|
|
|
291
|
+
const isAuthError = /Not logged in|no sync_sequence_id found|401|403/i.test(msg);
|
|
272
292
|
|
|
273
|
-
// For auth errors
|
|
293
|
+
// For transient auth errors (cookie/dtsg desync), do ONE
|
|
294
|
+
// refresh-and-retry, then give up instead of looping.
|
|
274
295
|
if (isAuthError) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
logger("getSeqID: refreshing session before retry...", "info");
|
|
285
|
-
const { get } = require("../../../utils/request");
|
|
286
|
-
const { saveCookies } = require("../../../utils/client");
|
|
287
|
-
await get("https://www.facebook.com/", ctx.jar, null, ctx.globalOptions, ctx).then(saveCookies(ctx.jar));
|
|
288
|
-
} catch (refreshErr) {
|
|
289
|
-
logger(`getSeqID: session refresh failed: ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
290
|
-
}
|
|
296
|
+
if (retryCount < 1 && ctx.loggedIn) {
|
|
297
|
+
logger(`getSeqID: auth error (${msg}) — one session refresh + retry`, "warn");
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
|
299
|
+
try {
|
|
300
|
+
const { get } = require("../../../utils/request");
|
|
301
|
+
const { saveCookies } = require("../../../utils/client");
|
|
302
|
+
await get("https://www.facebook.com/", ctx.jar, null, ctx.globalOptions, ctx).then(saveCookies(ctx.jar));
|
|
303
|
+
} catch (refreshErr) {
|
|
304
|
+
logger(`getSeqID: session refresh failed: ${refreshErr && refreshErr.message ? refreshErr.message : String(refreshErr)}`, "warn");
|
|
291
305
|
}
|
|
292
|
-
|
|
293
306
|
return getSeqID(defaultFuncs, api, ctx, globalCallback, form, retryCount + 1);
|
|
294
307
|
}
|
|
295
308
|
|
|
296
|
-
//
|
|
297
|
-
|
|
309
|
+
// Refresh+retry didn't help. Auto re-login only when explicitly
|
|
310
|
+
// enabled in fca-config.json (autoLogin: true).
|
|
298
311
|
const config = getConfig();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
312
|
+
if (config.autoLogin === true || config.autoLogin === "true") {
|
|
313
|
+
logger("getSeqID: attempting auto re-login (autoLogin enabled)...", "warn");
|
|
314
|
+
const loginResult = await tryAutoLogin(logger, config, ctx, defaultFuncs);
|
|
315
|
+
if (loginResult) {
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
317
|
+
return getSeqID(defaultFuncs, api, ctx, globalCallback, form, 0);
|
|
318
|
+
}
|
|
306
319
|
}
|
|
307
320
|
|
|
308
|
-
|
|
309
|
-
if (/blocked/i.test(msg)) {
|
|
310
|
-
return emitAuth(ctx, api, globalCallback, "login_blocked", msg);
|
|
311
|
-
}
|
|
312
|
-
if (/Not logged in/i.test(msg)) {
|
|
313
|
-
return emitAuth(ctx, api, globalCallback, "not_logged_in", msg);
|
|
314
|
-
}
|
|
321
|
+
return emitAuth(ctx, api, globalCallback, "not_logged_in", msg);
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
|
|
318
|
-
|
|
324
|
+
// Non-auth error (network hiccup, parse failure, ...): do NOT kill
|
|
325
|
+
// the session over it. Rethrow so the caller's autoReconnect retry
|
|
326
|
+
// (listenMqtt getSeqIDWrapper) handles it with its normal backoff.
|
|
327
|
+
logger(`getSeqID error (transient, will retry via reconnect): ${msg}`, "error");
|
|
328
|
+
throw err instanceof Error ? err : new Error(msg);
|
|
319
329
|
});
|
|
320
330
|
};
|
|
321
331
|
};
|
|
@@ -133,31 +133,52 @@ class E2EEBridge {
|
|
|
133
133
|
// Auto-reconnect the Noise WebSocket whenever it drops unexpectedly.
|
|
134
134
|
// `this.connected` stays true through reconnect cycles; it's only set
|
|
135
135
|
// false by an explicit api.e2ee.disconnect() call.
|
|
136
|
+
//
|
|
137
|
+
// Exponential backoff is critical here: a fixed 5s retry means that if
|
|
138
|
+
// FB is deliberately kicking the socket, we end up doing a fresh Noise
|
|
139
|
+
// handshake + prekey sync every ~11s — a loud automated-behavior signal
|
|
140
|
+
// on the server side. Back off 5s -> 10s -> 20s ... capped at 5min,
|
|
141
|
+
// and reset the counter after the connection has been stable for a
|
|
142
|
+
// while.
|
|
136
143
|
const _self = this;
|
|
144
|
+
_self._e2eeReconnectDelay = 5000;
|
|
145
|
+
_self._e2eeReconnectTimer = null;
|
|
146
|
+
_self._e2eeStableTimer = null;
|
|
147
|
+
function _markE2EEStable() {
|
|
148
|
+
_self._e2eeReconnectDelay = 5000;
|
|
149
|
+
if (_self._e2eeStableTimer) { clearTimeout(_self._e2eeStableTimer); _self._e2eeStableTimer = null; }
|
|
150
|
+
}
|
|
151
|
+
this.client.onEvent("e2ee_connected", function _onE2EEConnected() {
|
|
152
|
+
// Only count it as stable if it survived a bit — the reset timer
|
|
153
|
+
// is started below on reconnect success instead, since this event
|
|
154
|
+
// also fires on the very first connect.
|
|
155
|
+
});
|
|
137
156
|
this.client.onEvent("disconnected", function _onE2EEDisconnected() {
|
|
138
157
|
if (!_self.connected) return; // intentional disconnect — skip
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
if (_self._e2eeReconnectTimer) return; // a reconnect is already queued
|
|
159
|
+
if (_self._e2eeStableTimer) { clearTimeout(_self._e2eeStableTimer); _self._e2eeStableTimer = null; }
|
|
160
|
+
const delay = _self._e2eeReconnectDelay;
|
|
161
|
+
logger.warn("E2EE", "Noise WebSocket disconnected — reconnecting in " + Math.round(delay / 1000) + "s...");
|
|
162
|
+
_self._e2eeReconnectTimer = setTimeout(async function () {
|
|
163
|
+
_self._e2eeReconnectTimer = null;
|
|
141
164
|
if (!_self.connected) return;
|
|
142
165
|
try {
|
|
143
166
|
await _self.client.connectE2EE(_e2eeDevicePath, _e2eeUserId);
|
|
144
167
|
logger.success("E2EE", "E2EE WebSocket reconnected.");
|
|
168
|
+
// Next failure doubles the wait; reset only after the
|
|
169
|
+
// socket has stayed up for 10 minutes.
|
|
170
|
+
_self._e2eeReconnectDelay = Math.min(_self._e2eeReconnectDelay * 2, 5 * 60 * 1000);
|
|
171
|
+
_self._e2eeStableTimer = setTimeout(_markE2EEStable, 10 * 60 * 1000);
|
|
145
172
|
} catch (err) {
|
|
146
173
|
logger.error("E2EE", "E2EE reconnect failed: " + (err && err.message ? err.message : String(err)));
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
}
|
|
174
|
+
_self._e2eeReconnectDelay = Math.min(_self._e2eeReconnectDelay * 2, 5 * 60 * 1000);
|
|
175
|
+
// Schedule another attempt via the same back-off path.
|
|
176
|
+
_self._e2eeReconnectTimer = setTimeout(function () {
|
|
177
|
+
_self._e2eeReconnectTimer = null;
|
|
178
|
+
if (_self.connected) _onE2EEDisconnected();
|
|
179
|
+
}, _self._e2eeReconnectDelay);
|
|
159
180
|
}
|
|
160
|
-
},
|
|
181
|
+
}, delay);
|
|
161
182
|
});
|
|
162
183
|
|
|
163
184
|
// Forward incoming E2EE messages to the registered callback.
|
|
@@ -347,7 +368,23 @@ class E2EEBridge {
|
|
|
347
368
|
// decrypts these correctly, so group messages are received through it
|
|
348
369
|
// instead. DM receiving is untouched — it still goes exclusively
|
|
349
370
|
// through the vendor engine above, unchanged.
|
|
371
|
+
//
|
|
372
|
+
// IMPORTANT trade-off: the native engine and the vendored engine both
|
|
373
|
+
// register as the SAME E2EE device (same jidDevice). Facebook's server
|
|
374
|
+
// only allows ONE live E2EE socket per device, so when the native
|
|
375
|
+
// engine connects, the server kicks the vendored socket, and our
|
|
376
|
+
// reconnect then kicks the native one — an endless kick/ping-pong
|
|
377
|
+
// loop (a fresh Noise handshake every few seconds). Until the two
|
|
378
|
+
// engines can use distinct device ids, the native engine must stay
|
|
379
|
+
// OFF for the vendored socket to stay alive. Opt back in with
|
|
380
|
+
// FCA_E2EE_NATIVE=1 (group E2EE receive + native media sends) if you
|
|
381
|
+
// accept the resulting socket churn.
|
|
382
|
+
const _nativeEnabled = process.env.FCA_E2EE_NATIVE === "1";
|
|
383
|
+
if (!_nativeEnabled) {
|
|
384
|
+
logger.info("E2EE", "[native-group] native engine disabled (FCA_E2EE_NATIVE!=1) — vendored socket kept exclusive to avoid server-side device kicks");
|
|
385
|
+
}
|
|
350
386
|
try {
|
|
387
|
+
if (!_nativeEnabled) throw { _skipNative: true };
|
|
351
388
|
this._seenGroupMsgIds = this._seenGroupMsgIds || new Set();
|
|
352
389
|
// Fire-and-forget: do NOT await this. The native client's own
|
|
353
390
|
// connect (ICDC device registration etc.) can be slow/unpredictable,
|
|
@@ -426,8 +463,12 @@ class E2EEBridge {
|
|
|
426
463
|
(err && err.message ? err.message : String(err)));
|
|
427
464
|
});
|
|
428
465
|
} catch (err) {
|
|
429
|
-
|
|
430
|
-
|
|
466
|
+
if (err && err._skipNative) {
|
|
467
|
+
// intentional — native engine disabled, see note above
|
|
468
|
+
} else {
|
|
469
|
+
logger.error("E2EE", "[native-group] failed to attach native message listener (non-fatal, DM unaffected): " +
|
|
470
|
+
(err && err.message ? err.message : String(err)));
|
|
471
|
+
}
|
|
431
472
|
}
|
|
432
473
|
|
|
433
474
|
return this;
|
|
@@ -470,6 +511,12 @@ class E2EEBridge {
|
|
|
470
511
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
471
512
|
|
|
472
513
|
if (!attachment && isGroup) {
|
|
514
|
+
if (process.env.FCA_E2EE_NATIVE !== "1") {
|
|
515
|
+
// Native engine disabled (default) — it shares the same E2EE
|
|
516
|
+
// device id as the vendored socket and triggers server-side
|
|
517
|
+
// device kicks. Group E2EE text send unavailable in this mode.
|
|
518
|
+
throw new Error("E2EE group text send requires the native engine (FCA_E2EE_NATIVE=1), which is disabled to protect the vendored socket from server-side device kicks.");
|
|
519
|
+
}
|
|
473
520
|
// Vendor's own group Sender-Key encryption is broken (see native-group
|
|
474
521
|
// listener above) — group text sends go through native instead.
|
|
475
522
|
const result = await nativeMediaBridge.sendGroupMessage(this.api.getAppState(), threadId, text, replyToMessageId);
|
|
@@ -546,6 +593,9 @@ class E2EEBridge {
|
|
|
546
593
|
const appState = this.api.getAppState();
|
|
547
594
|
let result;
|
|
548
595
|
try {
|
|
596
|
+
if (process.env.FCA_E2EE_NATIVE !== "1") {
|
|
597
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1) — see native-group note");
|
|
598
|
+
}
|
|
549
599
|
result = await nativeMediaBridge.sendMedia(appState, threadId, mediaType, data, mimeType, {
|
|
550
600
|
caption: text || undefined,
|
|
551
601
|
width: dims ? dims.width : undefined,
|
|
@@ -640,7 +690,11 @@ class E2EEBridge {
|
|
|
640
690
|
|
|
641
691
|
async markRead(threadId, watermarkTs) {
|
|
642
692
|
this.ensureConnected();
|
|
643
|
-
|
|
693
|
+
if (process.env.FCA_E2EE_NATIVE === "1")
|
|
694
|
+
return nativeMediaBridge.markRead(this.api.getAppState(), threadId, watermarkTs);
|
|
695
|
+
// Native disabled: read receipts aren't available through the vendored
|
|
696
|
+
// engine — treat as a no-op rather than waking the native engine.
|
|
697
|
+
return undefined;
|
|
644
698
|
}
|
|
645
699
|
|
|
646
700
|
getThreadIdForMessage(messageId) {
|
|
@@ -657,6 +711,8 @@ class E2EEBridge {
|
|
|
657
711
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
658
712
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
659
713
|
try {
|
|
714
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
715
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1)");
|
|
660
716
|
return await nativeMediaBridge.sendReaction(this.api.getAppState(), threadId, messageId, senderJid, reaction, isGroup);
|
|
661
717
|
} catch (err) {
|
|
662
718
|
if (isGroup) {
|
|
@@ -678,6 +734,8 @@ class E2EEBridge {
|
|
|
678
734
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
679
735
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
680
736
|
try {
|
|
737
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
738
|
+
throw new Error("native engine disabled (FCA_E2EE_NATIVE!=1)");
|
|
681
739
|
return await nativeMediaBridge.unsendMessage(this.api.getAppState(), threadId, messageId, isGroup);
|
|
682
740
|
} catch (err) {
|
|
683
741
|
if (isGroup) {
|
|
@@ -694,6 +752,8 @@ class E2EEBridge {
|
|
|
694
752
|
this._knownE2EEGroups = this._knownE2EEGroups || new Set();
|
|
695
753
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
696
754
|
if (isGroup) {
|
|
755
|
+
if (process.env.FCA_E2EE_NATIVE !== "1")
|
|
756
|
+
throw new Error("E2EE group edit requires the native engine (FCA_E2EE_NATIVE=1), which is disabled.");
|
|
697
757
|
// Vendor's group Sender-Key encryption is broken, so route
|
|
698
758
|
// group edits through native (it doesn't need a chatJid — the
|
|
699
759
|
// messageId alone identifies which chat/message to edit).
|
|
@@ -727,10 +787,12 @@ class E2EEBridge {
|
|
|
727
787
|
}
|
|
728
788
|
|
|
729
789
|
async disconnect() {
|
|
790
|
+
this.connected = false; // set first so the disconnect handler won't queue a reconnect
|
|
791
|
+
if (this._e2eeReconnectTimer) { clearTimeout(this._e2eeReconnectTimer); this._e2eeReconnectTimer = null; }
|
|
792
|
+
if (this._e2eeStableTimer) { clearTimeout(this._e2eeStableTimer); this._e2eeStableTimer = null; }
|
|
730
793
|
if (this.client) {
|
|
731
794
|
try { await this.client.disconnect(); } catch (_) {}
|
|
732
795
|
}
|
|
733
|
-
this.connected = false;
|
|
734
796
|
logger.info("E2EE", "E2EE disconnected.");
|
|
735
797
|
}
|
|
736
798
|
}
|
|
@@ -98,24 +98,52 @@ function call(fn, payload) {
|
|
|
98
98
|
return data.data;
|
|
99
99
|
}
|
|
100
100
|
__name(call, "call");
|
|
101
|
+
|
|
102
|
+
// Real async FFI: koffi's .async runs the native call on libuv's threadpool
|
|
103
|
+
// instead of the main thread. The old callAsync() only deferred the call with
|
|
104
|
+
// setTimeout(0) but still executed it synchronously on the main thread, which
|
|
105
|
+
// froze the event loop for the entire duration of long native calls (media
|
|
106
|
+
// sends were observed blocking for 80+ seconds). A frozen event loop means
|
|
107
|
+
// MQTT keepalive pings never go out, so the server kills the socket
|
|
108
|
+
// (close 1000) and the session starts looking erratic to Facebook.
|
|
109
|
+
var _asyncFns = {};
|
|
110
|
+
function getAsyncFn(fn) {
|
|
111
|
+
if (!_asyncFns[fn]) {
|
|
112
|
+
// koffi 3.x: async is a member of the decoded function and takes a
|
|
113
|
+
// callback as the last argument (runs the native call on a libuv worker
|
|
114
|
+
// thread instead of blocking the main thread).
|
|
115
|
+
const bound = fns[fn];
|
|
116
|
+
_asyncFns[fn] = (input) => new Promise((resolve, reject) => {
|
|
117
|
+
bound.async(input, (err, res) => (err ? reject(err) : resolve(res)));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return _asyncFns[fn];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Serialize all native calls through a single FIFO queue. The Go side keeps
|
|
124
|
+
// per-handle state (device tables, socket, ratchet state) and the JS side
|
|
125
|
+
// shares one handle across every call, so parallel native invocations would
|
|
126
|
+
// interleave unpredictably. Serial + async gives non-blocking execution
|
|
127
|
+
// without any concurrency hazards.
|
|
128
|
+
var _nativeQueue = Promise.resolve();
|
|
101
129
|
function callAsync(fn, payload) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
reject(err);
|
|
109
|
-
}
|
|
110
|
-
}, 0);
|
|
130
|
+
const run = _nativeQueue.then(async () => {
|
|
131
|
+
const input = JSONBigNative.stringify(payload);
|
|
132
|
+
const out = await getAsyncFn(fn)(input);
|
|
133
|
+
const data = JSONBigNative.parse(out);
|
|
134
|
+
if (!data.ok) throw new Error(data.error || "Unknown error");
|
|
135
|
+
return data.data;
|
|
111
136
|
});
|
|
137
|
+
// A rejected call must not poison the queue for subsequent calls.
|
|
138
|
+
_nativeQueue = run.catch(() => {});
|
|
139
|
+
return run;
|
|
112
140
|
}
|
|
113
141
|
__name(callAsync, "callAsync");
|
|
114
142
|
var native = {
|
|
115
|
-
newClient: /* @__PURE__ */ __name((cfg) =>
|
|
116
|
-
connect: /* @__PURE__ */ __name((handle) =>
|
|
143
|
+
newClient: /* @__PURE__ */ __name((cfg) => callAsync("MxNewClient", cfg), "newClient"),
|
|
144
|
+
connect: /* @__PURE__ */ __name((handle) => callAsync("MxConnect", { handle }), "connect"),
|
|
117
145
|
connectE2EE: /* @__PURE__ */ __name((handle) => callAsync("MxConnectE2EE", { handle }), "connectE2EE"),
|
|
118
|
-
disconnect: /* @__PURE__ */ __name((handle) =>
|
|
146
|
+
disconnect: /* @__PURE__ */ __name((handle) => callAsync("MxDisconnect", { handle }), "disconnect"),
|
|
119
147
|
isConnected: /* @__PURE__ */ __name((handle) => call("MxIsConnected", { handle }), "isConnected"),
|
|
120
148
|
sendMessage: /* @__PURE__ */ __name((handle, options) => callAsync("MxSendMessage", { handle, options }), "sendMessage"),
|
|
121
149
|
sendReaction: /* @__PURE__ */ __name((handle, threadId, messageId, emoji) => callAsync("MxSendReaction", { handle, threadId, messageId, emoji }), "sendReaction"),
|
|
@@ -257,7 +285,7 @@ var Client = class extends EventEmitter {
|
|
|
257
285
|
* @returns User info and initial data
|
|
258
286
|
*/
|
|
259
287
|
async connect() {
|
|
260
|
-
const { handle } = native.newClient({
|
|
288
|
+
const { handle } = await native.newClient({
|
|
261
289
|
cookies: this.cookies,
|
|
262
290
|
platform: this.options.platform,
|
|
263
291
|
devicePath: this.options.devicePath,
|
|
@@ -266,7 +294,7 @@ var Client = class extends EventEmitter {
|
|
|
266
294
|
logLevel: this.options.logLevel
|
|
267
295
|
});
|
|
268
296
|
this.handle = handle;
|
|
269
|
-
const result = native.connect(handle);
|
|
297
|
+
const result = await native.connect(handle);
|
|
270
298
|
this._user = result.user;
|
|
271
299
|
this._initialData = result.initialData;
|
|
272
300
|
this.startEventLoop();
|
|
@@ -295,7 +323,7 @@ var Client = class extends EventEmitter {
|
|
|
295
323
|
async disconnect() {
|
|
296
324
|
this.stopEventLoop();
|
|
297
325
|
if (this.handle) {
|
|
298
|
-
native.disconnect(this.handle);
|
|
326
|
+
await native.disconnect(this.handle);
|
|
299
327
|
this.handle = null;
|
|
300
328
|
}
|
|
301
329
|
}
|
|
@@ -20,10 +20,45 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const path = require("path");
|
|
23
|
+
const fs = require("fs");
|
|
23
24
|
const logger = require("../../../../utils/nexca-logger");
|
|
24
25
|
|
|
25
26
|
const NATIVE_CALL_TIMEOUT_MS = 15000;
|
|
26
27
|
|
|
28
|
+
// Where the native client's E2EE device identity is persisted between
|
|
29
|
+
// restarts. With memory-only storage every bot restart registers a brand-new
|
|
30
|
+
// E2EE device with Facebook — on an already-flagged account, "yet another
|
|
31
|
+
// new device" per restart is exactly the kind of signal that keeps the
|
|
32
|
+
// account under scrutiny.
|
|
33
|
+
function _deviceDataPath() {
|
|
34
|
+
return path.join(process.cwd(), ".nexca", "native_e2ee_device.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _loadDeviceData() {
|
|
38
|
+
try {
|
|
39
|
+
const p = _deviceDataPath();
|
|
40
|
+
if (fs.existsSync(p)) {
|
|
41
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (parsed && typeof parsed.deviceData === "string" && parsed.deviceData.length) {
|
|
44
|
+
return parsed.deviceData;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (_) { /* corrupted/absent store — start fresh */ }
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _saveDeviceData(client) {
|
|
52
|
+
try {
|
|
53
|
+
if (!client || typeof client.getDeviceData !== "function") return;
|
|
54
|
+
const deviceData = client.getDeviceData();
|
|
55
|
+
if (!deviceData || typeof deviceData !== "string") return;
|
|
56
|
+
const p = _deviceDataPath();
|
|
57
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
58
|
+
fs.writeFileSync(p, JSON.stringify({ deviceData, savedAt: Date.now() }), "utf8");
|
|
59
|
+
} catch (_) { /* best-effort persistence */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
/**
|
|
28
63
|
* Wraps a native-engine call with a hard timeout. A hung native call (e.g.
|
|
29
64
|
* a stuck getDeviceList/usync query) was observed blocking the whole
|
|
@@ -95,9 +130,12 @@ async function getClient(appState) {
|
|
|
95
130
|
throw new Error("Native E2EE media engine: c_user/xs cookies missing from appState");
|
|
96
131
|
}
|
|
97
132
|
|
|
133
|
+
const storedDeviceData = _loadDeviceData();
|
|
134
|
+
|
|
98
135
|
_clientInstance = new mod.Client(cookies, {
|
|
99
136
|
enableE2EE: true,
|
|
100
137
|
e2eeMemoryOnly: true,
|
|
138
|
+
deviceData: storedDeviceData || undefined,
|
|
101
139
|
autoReconnect: true,
|
|
102
140
|
logLevel: "none"
|
|
103
141
|
});
|
|
@@ -105,6 +143,7 @@ async function getClient(appState) {
|
|
|
105
143
|
_clientInstance.on("error", (err) => {
|
|
106
144
|
logger.warn("E2EE", "[native-media] " + (err && err.message ? err.message : String(err)));
|
|
107
145
|
});
|
|
146
|
+
_clientInstance.on("deviceDataChanged", () => _saveDeviceData(_clientInstance));
|
|
108
147
|
_clientInstance.on("disconnected", () => {
|
|
109
148
|
logger.warn("E2EE", "[native-media] native client disconnected, will reconnect on next send");
|
|
110
149
|
_connected = false;
|
|
@@ -119,6 +158,7 @@ async function getClient(appState) {
|
|
|
119
158
|
await _clientInstance.connect();
|
|
120
159
|
await _clientInstance.connectE2EE();
|
|
121
160
|
_connected = true;
|
|
161
|
+
_saveDeviceData(_clientInstance);
|
|
122
162
|
|
|
123
163
|
return _clientInstance;
|
|
124
164
|
})();
|
|
@@ -1285,8 +1285,8 @@ var ProtoWriter = class {
|
|
|
1285
1285
|
// src/e2ee/message/builders/client-payload.ts
|
|
1286
1286
|
function encodeClientPayload(opts) {
|
|
1287
1287
|
const appVersion = new ProtoWriter().varint(1, 301).varint(2, 0).varint(3, 2).build();
|
|
1288
|
-
const userAgent = new ProtoWriter().varint(1, 32).bytes(2, appVersion).string(3, "000").string(4, "000").string(5, "").string(6, "
|
|
1289
|
-
const UserAgentStr = "Mozilla/5.0 (
|
|
1288
|
+
const userAgent = new ProtoWriter().varint(1, 32).bytes(2, appVersion).string(3, "000").string(4, "000").string(5, "").string(6, "Windows").string(7, "Chrome").string(8, "").varint(10, 3).string(11, "en").string(12, "en").build();
|
|
1289
|
+
const UserAgentStr = process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
1290
1290
|
let w = new ProtoWriter().uint64_varint(1, opts.username).bool(3, false).bytes(5, userAgent).varint(12, 1).varint(13, 1).varint(18, opts.deviceId).varint(20, 1).bytes(21, opts.fbCatBase64 ? Buffer.from(opts.fbCatBase64) : Buffer.alloc(0)).bytes(22, opts.fbUserAgent ?? Buffer.from(UserAgentStr)).bool(33, true);
|
|
1291
1291
|
return w.build();
|
|
1292
1292
|
}
|
|
@@ -3439,9 +3439,9 @@ var FacebookE2EESocket = class extends import_node_events2.EventEmitter {
|
|
|
3439
3439
|
if (cookies) this.cookieHeader = cookies;
|
|
3440
3440
|
const wsUrl = new URL(this.url);
|
|
3441
3441
|
wsUrl.searchParams.set("cid", "client-" + Date.now());
|
|
3442
|
-
const UserAgentStr = "Mozilla/5.0 (
|
|
3442
|
+
const UserAgentStr = process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
3443
3443
|
const wsHeaders = {
|
|
3444
|
-
"Origin": "https://www.
|
|
3444
|
+
"Origin": "https://www.messenger.com",
|
|
3445
3445
|
"User-Agent": UserAgentStr
|
|
3446
3446
|
};
|
|
3447
3447
|
if (this.cookieHeader) {
|
|
@@ -6370,7 +6370,7 @@ var FBClient = class {
|
|
|
6370
6370
|
const threadService = new ThreadService(mediaService);
|
|
6371
6371
|
const e2eeService = new E2EEService();
|
|
6372
6372
|
const icdcService = new ICDCService(
|
|
6373
|
-
"Mozilla/5.0 (
|
|
6373
|
+
process.env.FCA_E2EE_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
|
|
6374
6374
|
);
|
|
6375
6375
|
this.controller = new ClientController(
|
|
6376
6376
|
authService,
|
|
@@ -18,7 +18,13 @@ const getTaskResponseData = require("./core/getTaskResponseData");
|
|
|
18
18
|
const createEmitAuth = require("./core/emitAuth");
|
|
19
19
|
const createMiddlewareSystem = require("./middleware");
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Auto-cycle is OFF by default now: a real browser keeps one MQTT socket
|
|
22
|
+
// open for hours. Cycling every exactly-3600.000s (full unsubscribe,
|
|
23
|
+
// /browser_close, two page loads, re-auth, resubscribe) on a clockwork
|
|
24
|
+
// schedule is itself a robot signature, and each cycle is a burst of
|
|
25
|
+
// session-touching requests. Reconnects still happen on actual socket
|
|
26
|
+
// close/error. Pass { cycleMs: <ms> } to listenMqtt to re-enable.
|
|
27
|
+
const CYCLE_MS_DEFAULT = 0;
|
|
22
28
|
const RECONNECT_DELAY_MS_DEFAULT = 2000;
|
|
23
29
|
const UNSUB_ALL_TIMEOUT_MS = 5000;
|
|
24
30
|
|
|
@@ -221,7 +227,25 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
221
227
|
function delayedReconnect() {
|
|
222
228
|
const d = conf.reconnectDelayMs;
|
|
223
229
|
logger(`mqtt reconnect in ${d}ms`, "info");
|
|
224
|
-
setTimeout(() =>
|
|
230
|
+
setTimeout(() => refreshSessionAndReconnect(), d);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function refreshSessionAndReconnect() {
|
|
234
|
+
logger("Refreshing session before MQTT reconnection to avoid cookie/dtsg desync", "info");
|
|
235
|
+
defaultFuncs.get("https://www.messenger.com/", ctx.jar, null, ctx)
|
|
236
|
+
.then(() => {
|
|
237
|
+
if (typeof api.refreshFb_dtsg === "function") {
|
|
238
|
+
return api.refreshFb_dtsg().then(() => {
|
|
239
|
+
logger("fb_dtsg refreshed before reconnect", "info");
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
.catch((err) => {
|
|
244
|
+
logger(`Session refresh before reconnect failed (non-fatal): ${err && err.message ? err.message : String(err)}`, "warn");
|
|
245
|
+
})
|
|
246
|
+
.finally(() => {
|
|
247
|
+
getSeqIDWrapper();
|
|
248
|
+
});
|
|
225
249
|
}
|
|
226
250
|
|
|
227
251
|
function forceCycle() {
|
|
@@ -237,21 +261,31 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
237
261
|
|
|
238
262
|
function startSessionKeeper() {
|
|
239
263
|
if (ctx._sessionKeeperInterval) return;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
264
|
+
// Self-rescheduling timeout with ±15 min jitter around 90 min, instead of
|
|
265
|
+
// a clockwork-exact 90:00.000 interval — perfectly periodic traffic is
|
|
266
|
+
// trivial to fingerprint.
|
|
267
|
+
const BASE_MS = 90 * 60 * 1000;
|
|
268
|
+
const JITTER_MS = 15 * 60 * 1000;
|
|
269
|
+
const scheduleNext = function () {
|
|
270
|
+
const delay = BASE_MS - JITTER_MS + Math.floor(Math.random() * 2 * JITTER_MS);
|
|
271
|
+
ctx._sessionKeeperInterval = setTimeout(function () {
|
|
272
|
+
if (!ctx.loggedIn) { scheduleNext(); return; }
|
|
273
|
+
logger("sessionKeeper: refreshing cookies + fb_dtsg", "info");
|
|
274
|
+
defaultFuncs.get("https://www.messenger.com/", ctx.jar, null, ctx)
|
|
275
|
+
.then(function () {
|
|
276
|
+
if (typeof api.refreshFb_dtsg === "function") return api.refreshFb_dtsg();
|
|
277
|
+
})
|
|
278
|
+
.then(function () {
|
|
279
|
+
logger("sessionKeeper: refresh done", "info");
|
|
280
|
+
})
|
|
281
|
+
.catch(function (err) {
|
|
282
|
+
logger(`sessionKeeper: refresh failed - ${err && err.message ? err.message : String(err)}`, "warn");
|
|
283
|
+
})
|
|
284
|
+
.finally(scheduleNext);
|
|
285
|
+
}, delay);
|
|
286
|
+
if (ctx._sessionKeeperInterval.unref) ctx._sessionKeeperInterval.unref();
|
|
287
|
+
};
|
|
288
|
+
scheduleNext();
|
|
255
289
|
}
|
|
256
290
|
|
|
257
291
|
function startHeartbeat() {
|
|
@@ -259,9 +293,12 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
259
293
|
ctx.lastMqttActivity = Date.now();
|
|
260
294
|
ctx._heartbeatInterval = setInterval(function () {
|
|
261
295
|
if (!ctx.loggedIn) return;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
296
|
+
// Liveness is the socket's own state + mqtt.js keepalive, not "did a
|
|
297
|
+
// chat message arrive". The old check force-cycled a perfectly healthy
|
|
298
|
+
// connection every 45 quiet minutes (e.g. overnight), producing
|
|
299
|
+
// re-auth bursts exactly when a real user would be asleep.
|
|
300
|
+
if (!isConnected() && !ctx._ending && !ctx._cycling) {
|
|
301
|
+
logger("heartbeat: MQTT socket not connected, forcing reconnect", "warn");
|
|
265
302
|
ctx.lastMqttActivity = Date.now();
|
|
266
303
|
forceCycle();
|
|
267
304
|
}
|
package/src/utils/headers.js
CHANGED
|
@@ -37,6 +37,46 @@ function sanitizeHeaderName(name) {
|
|
|
37
37
|
return name.replace(/[^\x21-\x7E]/g, "").trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Derive the Sec-CH-UA client-hint set from the configured User-Agent instead
|
|
41
|
+
// of hardcoding it. Previously the hints always claimed "Windows desktop
|
|
42
|
+
// Chrome 139" even when the UA said "Android Chrome 101" — every single
|
|
43
|
+
// request then contradicted itself, which is a trivial server-side check.
|
|
44
|
+
function clientHintsFromUA(ua) {
|
|
45
|
+
const s = String(ua || "");
|
|
46
|
+
const chromeMatch = s.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
|
|
47
|
+
const major = chromeMatch ? chromeMatch[1] : "139";
|
|
48
|
+
const full = chromeMatch ? chromeMatch[0].split("/")[1] : `${major}.0.0.0`;
|
|
49
|
+
const isMobile = /Mobile|Android|iPhone|iPad/i.test(s);
|
|
50
|
+
let platform = "Windows";
|
|
51
|
+
let platformVersion = "\"15.0.0\"";
|
|
52
|
+
if (/Android\s*(\d+)/i.test(s)) {
|
|
53
|
+
platform = "Android";
|
|
54
|
+
platformVersion = `"${s.match(/Android\s*(\d+(?:\.\d+)?)/i)[1]}.0"`;
|
|
55
|
+
} else if (/iPhone|iPad/i.test(s)) {
|
|
56
|
+
platform = "iOS";
|
|
57
|
+
platformVersion = "\"17.0.0\"";
|
|
58
|
+
} else if (/Mac OS X/i.test(s)) {
|
|
59
|
+
platform = "macOS";
|
|
60
|
+
platformVersion = "\"14.0.0\"";
|
|
61
|
+
} else if (/Linux/i.test(s) && !/Android/i.test(s)) {
|
|
62
|
+
platform = "Linux";
|
|
63
|
+
platformVersion = "\"\"";
|
|
64
|
+
}
|
|
65
|
+
const hints = {
|
|
66
|
+
"sec-ch-ua": `"Chromium";v="${major}", "Not;A=Brand";v="24", "Google Chrome";v="${major}"`,
|
|
67
|
+
"sec-ch-ua-mobile": isMobile ? "?1" : "?0",
|
|
68
|
+
"sec-ch-ua-platform": `"${platform}"`,
|
|
69
|
+
"sec-ch-ua-full-version-list": `"Chromium";v="${full}", "Not;A=Brand";v="24.0.0.0", "Google Chrome";v="${full}"`,
|
|
70
|
+
"sec-ch-ua-platform-version": platformVersion
|
|
71
|
+
};
|
|
72
|
+
// Arch/bitness hints are only sent by desktop Chrome builds.
|
|
73
|
+
if (!isMobile) {
|
|
74
|
+
hints["sec-ch-ua-arch"] = "\"x86\"";
|
|
75
|
+
hints["sec-ch-ua-bitness"] = "\"64\"";
|
|
76
|
+
}
|
|
77
|
+
return hints;
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
function getHeaders(url, options, ctx, customHeader) {
|
|
41
81
|
const u = new URL(url);
|
|
42
82
|
const ua = options?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
|
|
@@ -44,11 +84,12 @@ function getHeaders(url, options, ctx, customHeader) {
|
|
|
44
84
|
const origin = referer.replace(/\/+$/, "");
|
|
45
85
|
const contentType = options?.contentType || "application/x-www-form-urlencoded";
|
|
46
86
|
const acceptLang = options?.acceptLanguage || "en-US,en;q=0.9,vi;q=0.8";
|
|
47
|
-
|
|
87
|
+
// NOTE: keep the && chain parenthesized — `||` binds looser than `&&`.
|
|
88
|
+
const isNavigation = (u.pathname === "/" &&
|
|
48
89
|
!url.includes("ajax") &&
|
|
49
90
|
!url.includes("graphql") &&
|
|
50
|
-
!
|
|
51
|
-
!url.includes("chat") ||
|
|
91
|
+
!u.pathname.includes("api") &&
|
|
92
|
+
!url.includes("chat")) ||
|
|
52
93
|
url.includes("checkpoint");
|
|
53
94
|
const acceptHeader = isNavigation
|
|
54
95
|
? "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
|
|
@@ -65,13 +106,7 @@ function getHeaders(url, options, ctx, customHeader) {
|
|
|
65
106
|
Connection: "keep-alive",
|
|
66
107
|
DNT: "1",
|
|
67
108
|
"Upgrade-Insecure-Requests": "1",
|
|
68
|
-
|
|
69
|
-
"sec-ch-ua-mobile": "?0",
|
|
70
|
-
"sec-ch-ua-platform": "\"Windows\"",
|
|
71
|
-
"sec-ch-ua-arch": "\"x86\"",
|
|
72
|
-
"sec-ch-ua-bitness": "\"64\"",
|
|
73
|
-
"sec-ch-ua-full-version-list": "\"Chromium\";v=\"139.0.0.0\", \"Not;A=Brand\";v=\"24.0.0.0\", \"Google Chrome\";v=\"139.0.0.0\"",
|
|
74
|
-
"sec-ch-ua-platform-version": "\"15.0.0\"",
|
|
109
|
+
...clientHintsFromUA(ua),
|
|
75
110
|
"Sec-Fetch-Site": isNavigation ? "none" : "same-origin",
|
|
76
111
|
"Sec-Fetch-Mode": isNavigation ? "navigate" : "cors",
|
|
77
112
|
"Sec-Fetch-Dest": isNavigation ? "document" : "empty",
|