mini-fca 0.1.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.
@@ -0,0 +1,28 @@
1
+ name: Publish npm
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Setup Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ registry-url: https://registry.npmjs.org
21
+
22
+ - name: Install dependencies
23
+ run: npm install
24
+
25
+ - name: Publish to NPM
26
+ run: npm publish --access public
27
+ env:
28
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # mini-fca
2
+
3
+ Minimal unofficial Facebook Messenger chat API for Node.js. Reverse-engineered
4
+ from Facebook's web client (ajax endpoints + MQTT over WebSocket) — this is
5
+ **not** Meta's official API, and it *will* break when Facebook changes internal
6
+ endpoints, GraphQL `doc_id` values, or page markup. Untested against a live
7
+ account; treat as a starting skeleton, not a finished product.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install
13
+ ```
14
+
15
+ Requires Node 18+ (uses global `fetch`, `FormData`, `Blob`).
16
+
17
+ ## Setup
18
+
19
+ 1. Log into Facebook in a browser with the account you'll automate — **use a
20
+ throwaway/test account**, not your main one.
21
+ 2. Export cookies as AppState JSON, e.g. with the `c3c-fbstate` browser
22
+ extension. Save it as `appstate.json` next to `example.js`.
23
+ 3. `node example.js`
24
+
25
+ ## API
26
+
27
+ ```js
28
+ const login = require("./index");
29
+
30
+ login({ appState, options: { /* see below */ } }, (err, api) => {
31
+ // or: const api = await login({ appState });
32
+ });
33
+ ```
34
+
35
+ ### Options
36
+
37
+ | key | default | meaning |
38
+ |---|---|---|
39
+ | `selfListen` | `false` | also emit events for messages you send |
40
+ | `listenEvents` | `true` | emit group/thread events, not just messages |
41
+ | `sendDelayMs` | `1200` | minimum gap between outgoing sends (throttling) |
42
+ | `autoReconnect` | `true` | reconnect MQTT with backoff on drop |
43
+ | `maxRetries` | `20` | give up after this many reconnect attempts |
44
+
45
+ ### Methods
46
+
47
+ - `api.sendMessage(body | {body, attachment, sticker, mentions, replyTo}, threadID, {isGroup})`
48
+ - `api.unsendMessage(messageID)`
49
+ - `api.setMessageReaction(emoji, messageID)` — pass `""` to remove
50
+ - `api.markAsRead(threadID, read=true)`
51
+ - `api.sendTypingIndicator(threadID, isTyping, isGroup)`
52
+ - `api.uploadAttachment(path | Buffer | {buffer, filename, contentType} | stream)`
53
+ - `api.getUserInfo(userID)`
54
+ - `api.getFriendsList()`
55
+ - `api.getThreadInfo(threadID)`
56
+ - `api.getThreadList(limit, offset)`
57
+ - `api.getThreadHistory(threadID, amount, beforeTimestamp, isGroup)`
58
+ - `api.changeNickname(nickname, threadID, participantID)`
59
+ - `api.setTitle(newTitle, threadID)`
60
+ - `api.addUserToGroup(userIDs, threadID)`
61
+ - `api.removeUserFromGroup(userID, threadID)`
62
+ - `api.createGroup(userIDs, title?)` — needs ≥2 other participants, returns new `threadID`
63
+ - `api.changeAdminStatus(threadID, userID, makeAdmin=true)`
64
+ - `api.changeThreadColor(threadID, hexColor)`
65
+ - `api.changeThreadEmoji(threadID, emoji)`
66
+ - `api.muteThread(threadID, muteSeconds)` — `-1` mute indefinitely, `0` unmute
67
+ - `api.pinMessage(threadID, messageID, pinned=true)`
68
+ - `api.createPoll(threadID, question, options[], multiSelect=false)` → returns `pollID`
69
+ - `api.voteInPoll(pollID, optionIDs[])`
70
+ - `api.blockUser(userID)` / `api.unblockUser(userID)`
71
+ - `api.searchThreads(query, limit)` — find people/groups/pages by name
72
+ - `api.searchMessages(threadID, query, limit)` — search within one thread's history
73
+ - `api.uploadLargeAttachment(file, {chunkSizeBytes})` — chunked upload for large video/files, falls back to `uploadAttachment` under the chunk size
74
+ - `api.listen(callback)` / `api.listenMqtt(callback)` — returns a `stop()` function
75
+ - `api.getAppState()` — export refreshed cookies to persist between runs
76
+ - `api.setOptions(partial)`, `api.refreshTokens()`
77
+
78
+ ### Listen events
79
+
80
+ `callback(err, event)` where `event.type` is one of:
81
+
82
+ - `"message"` — `{threadID, senderID, messageID, body, isGroup, attachments, reply()}`
83
+ - `"event"` — `logMessageType` of `log:subscribe` / `log:unsubscribe` / `log:thread-name`
84
+ - `"message_reaction"` — `{threadID, messageID, userID, reaction, action}`
85
+ - `"message_unsend"` — `{threadID, messageID, senderID}`
86
+ - `"read_receipt"` — `{threadID, readerID, time}`
87
+ - `"typ"` — typing indicator
88
+ - `"system"` — connection state (`connected`, `reconnecting`)
89
+
90
+ ## Known weak points
91
+
92
+ - `src/listen.js` `fetchSeqID()` calls a GraphQL `doc_id` that Facebook rotates
93
+ periodically. If `listen()` fails immediately, this is the first suspect —
94
+ capture the endpoint again from browser devtools and swap the id.
95
+ - `pinMessage()` and `setMessageReaction()` also call `webgraphql/mutation/`
96
+ with hardcoded `doc_id`s — same rotation risk as above.
97
+ - Legacy `ajax/mercury/*` endpoints (`thread_info.php`, `threadlist_info.php`,
98
+ `search_snippets.php`) may return partial data or be deprecated outright.
99
+ - `searchMessages()`'s response shape is the least certain endpoint in this
100
+ library — Facebook has changed its message-search backend multiple times;
101
+ treat the return value as "probably an array of snippet objects" and log
102
+ the raw response the first time you call it.
103
+ - `uploadLargeAttachment()`'s resumable-upload endpoints
104
+ (`upload_resumable_start/chunk/finish.php`) are named by analogy with how
105
+ Facebook's own resumable upload flow works elsewhere; they have not been
106
+ confirmed against Messenger's current upload pipeline. For anything under
107
+ ~25MB, prefer plain `uploadAttachment()`.
108
+ - `createGroup()` requires at least 2 other participants (Facebook's own
109
+ restriction) — passing 1 will silently produce a 1-to-1 thread, not a
110
+ group, and the returned `threadID` won't behave like a group thread.
111
+ - No captcha/checkpoint handling — if Facebook interrupts login with a
112
+ checkpoint, `refreshTokens()` will throw and you resolve it manually in a
113
+ browser, then re-export AppState.
114
+ - Not implemented at all: voice/video calls (needs a full WebRTC signaling +
115
+ media stack, out of scope for an HTTP/MQTT client), Stories, vanish mode.
116
+
117
+ ## Risk notes
118
+
119
+ - Automating a personal account this way violates Facebook's Terms of
120
+ Service; the account can be checkpointed or banned regardless of how
121
+ careful the code is.
122
+ - `appstate.json` is equivalent to your login session — never commit it or
123
+ share it.
124
+ - For anything long-running or Page-based, Meta's official Messenger
125
+ Platform API is the stable, ToS-compliant alternative.
package/index.js ADDED
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const Http = require("./src/http");
5
+ const { buildApi } = require("./src/api");
6
+
7
+ const DEFAULT_OPTIONS = {
8
+ selfListen: false, // নিজের পাঠানো message-এও event পাবে কিনা
9
+ listenEvents: true, // group join/leave, reaction, typing ইত্যাদি event
10
+ sendDelayMs: 1200, // দুটি message-এর মাঝে ন্যূনতম বিরতি (ban ঝুঁকি কমায়)
11
+ autoReconnect: true,
12
+ maxRetries: 20,
13
+ };
14
+
15
+ function computeJazoest(dtsg) {
16
+ let sum = 0;
17
+ for (let i = 0; i < dtsg.length; i++) sum += dtsg.charCodeAt(i);
18
+ return "2" + sum;
19
+ }
20
+
21
+ function parseHome(html) {
22
+ const dtsg = (
23
+ html.match(/"DTSGInitialData",\[\],\{"token":"([^"]+)"/) ||
24
+ html.match(/name="fb_dtsg" value="([^"]+)"/) ||
25
+ html.match(/"dtsg":\{"token":"([^"]+)"/) ||
26
+ []
27
+ )[1];
28
+ if (!dtsg) {
29
+ throw new Error(
30
+ "fb_dtsg not found: AppState expired, checkpoint required, or Facebook changed its page layout"
31
+ );
32
+ }
33
+ const jazoest = (html.match(/name="jazoest" value="(\d+)"/) || [])[1] || computeJazoest(dtsg);
34
+ const region = ((html.match(/"region":"([a-z]{3})"/i) || [])[1] || "prn").toLowerCase();
35
+ return { dtsg, jazoest, region };
36
+ }
37
+
38
+ async function doLogin(credentials) {
39
+ if (!credentials || !Array.isArray(credentials.appState)) {
40
+ throw new Error("appState (array of cookies) is required");
41
+ }
42
+
43
+ const http = new Http(credentials.appState);
44
+ const userID = http.jar.get("c_user");
45
+ if (!userID) throw new Error("c_user cookie missing: AppState is invalid or expired");
46
+
47
+ const ctx = {
48
+ http,
49
+ userID,
50
+ fbDtsg: "",
51
+ jazoest: "",
52
+ region: "prn",
53
+ clientID: crypto.randomBytes(4).toString("hex"),
54
+ req: 0,
55
+ lastSeqId: null,
56
+ syncToken: null,
57
+ options: { ...DEFAULT_OPTIONS, ...(credentials.options || {}) },
58
+
59
+ // Common form fields Facebook expects on ajax calls
60
+ form(extra = {}) {
61
+ ctx.req += 1;
62
+ return {
63
+ __user: userID,
64
+ __a: "1",
65
+ __req: ctx.req.toString(36),
66
+ __rev: "1000000000",
67
+ fb_dtsg: ctx.fbDtsg,
68
+ jazoest: ctx.jazoest,
69
+ ...extra,
70
+ };
71
+ },
72
+
73
+ // Re-scrape tokens (used on reconnect / after session hiccups)
74
+ async refreshTokens() {
75
+ const html = await http.getText("https://www.facebook.com/");
76
+ const t = parseHome(html);
77
+ ctx.fbDtsg = t.dtsg;
78
+ ctx.jazoest = t.jazoest;
79
+ ctx.region = t.region;
80
+ },
81
+ };
82
+
83
+ await ctx.refreshTokens();
84
+ return buildApi(ctx);
85
+ }
86
+
87
+ // Works both with callback and with promises
88
+ function login(credentials, callback) {
89
+ const p = doLogin(credentials);
90
+ if (typeof callback === "function") {
91
+ p.then((api) => callback(null, api)).catch((err) => callback(err));
92
+ return;
93
+ }
94
+ return p;
95
+ }
96
+
97
+ module.exports = login;
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "mini-fca",
3
+ "version": "0.1.0",
4
+ "description": "Minimal unofficial Facebook Messenger chat API for Node.js (experimental, untested)",
5
+ "main": "index.js",
6
+ "engines": { "node": ">=18" },
7
+ "dependencies": {
8
+ "mqtt": "^4.3.8"
9
+ }
10
+ }
package/src/api.js ADDED
@@ -0,0 +1,480 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { createListener } = require("./listen");
6
+
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+
9
+ const MIME = {
10
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
11
+ ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg",
12
+ ".m4a": "audio/mp4", ".ogg": "audio/ogg", ".wav": "audio/wav", ".pdf": "application/pdf",
13
+ ".txt": "text/plain", ".zip": "application/zip",
14
+ };
15
+
16
+ // Offline threading ID: timestamp bits + 22 random bits, as a decimal string
17
+ function genOfflineThreadingID() {
18
+ const now = Date.now();
19
+ const rand = Math.floor(Math.random() * 4294967295);
20
+ const bits = ("0000000000000000000000" + rand.toString(2)).slice(-22);
21
+ return BigInt("0b" + now.toString(2) + bits).toString();
22
+ }
23
+
24
+ async function streamToBuffer(stream) {
25
+ const chunks = [];
26
+ for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
27
+ return Buffer.concat(chunks);
28
+ }
29
+
30
+ // Accepts: file path | Buffer | { buffer, filename, contentType } | readable stream
31
+ async function toFile(att) {
32
+ let buffer, filename = "file", ext = "";
33
+ if (typeof att === "string") {
34
+ buffer = await fs.promises.readFile(att);
35
+ filename = path.basename(att);
36
+ } else if (Buffer.isBuffer(att)) {
37
+ buffer = att;
38
+ } else if (att && att.buffer) {
39
+ buffer = att.buffer;
40
+ filename = att.filename || filename;
41
+ } else if (att && typeof att.pipe === "function") {
42
+ buffer = await streamToBuffer(att);
43
+ if (att.path) filename = path.basename(String(att.path));
44
+ } else {
45
+ throw new Error("Unsupported attachment type");
46
+ }
47
+ ext = path.extname(filename).toLowerCase();
48
+ const contentType = (att && att.contentType) || MIME[ext] || "application/octet-stream";
49
+ return { buffer, filename, contentType };
50
+ }
51
+
52
+ function buildApi(ctx) {
53
+ const api = {};
54
+
55
+ // ---------- internal helpers ----------
56
+ const call = async (url, extra = {}) => {
57
+ const res = await ctx.http.postJson(url, ctx.form(extra));
58
+ if (res.error) {
59
+ const e = new Error(
60
+ `${url.split("/").slice(-2).join("/")} failed: ${res.errorDescription || res.errorSummary || res.error}`
61
+ );
62
+ e.code = res.error;
63
+ e.response = res;
64
+ throw e;
65
+ }
66
+ return res;
67
+ };
68
+
69
+ // Serialize sends with a minimum delay to reduce spam detection
70
+ let chain = Promise.resolve();
71
+ const enqueue = (fn) => {
72
+ const run = chain.then(fn);
73
+ chain = run.catch(() => {}).then(() => sleep(ctx.options.sendDelayMs));
74
+ return run;
75
+ };
76
+
77
+ const messageBase = () => {
78
+ const otid = genOfflineThreadingID();
79
+ return {
80
+ otid,
81
+ fields: {
82
+ client: "mercury",
83
+ action_type: "ma-type:user-generated-message",
84
+ author: "fbid:" + ctx.userID,
85
+ timestamp: Date.now(),
86
+ source: "source:chat:web",
87
+ "source_tags[0]": "source:chat",
88
+ html_body: false,
89
+ ui_push_phase: "V3",
90
+ status: "0",
91
+ offline_threading_id: otid,
92
+ message_id: otid,
93
+ threading_id: `<${Date.now()}:${Math.floor(Math.random() * 4294967295)}-${ctx.clientID}@mail.projektitan.com>`,
94
+ "ephemeral_ttl_mode:": "0",
95
+ manual_retry_cnt: "0",
96
+ has_attachment: false,
97
+ signatureID: Math.floor(Math.random() * 2147483648).toString(16),
98
+ },
99
+ };
100
+ };
101
+
102
+ const SEND_URL = "https://www.facebook.com/messaging/send/";
103
+
104
+ // ---------- session ----------
105
+ api.getCurrentUserID = () => ctx.userID;
106
+ api.getAppState = () => ctx.http.exportAppState();
107
+ api.setOptions = (opts) => Object.assign(ctx.options, opts);
108
+ api.refreshTokens = () => ctx.refreshTokens();
109
+
110
+ // ---------- attachments ----------
111
+ api.uploadAttachment = async (att) => {
112
+ const file = await toFile(att);
113
+ const res = await ctx.http.postMultipart(
114
+ "https://upload.facebook.com/ajax/mercury/upload.php",
115
+ ctx.form({ voice_clip: "false" }),
116
+ { field: "upload_1024", ...file }
117
+ );
118
+ if (res.error) throw new Error("upload failed: " + (res.errorDescription || res.error));
119
+ const meta = res.payload && res.payload.metadata && res.payload.metadata[0];
120
+ if (!meta) throw new Error("upload failed: empty metadata");
121
+ const key = Object.keys(meta).find((k) => /_id$/.test(k));
122
+ if (!key) throw new Error("upload failed: unknown metadata shape");
123
+ return { type: key, id: meta[key] }; // e.g. { type: "image_id", id: "123..." }
124
+ };
125
+
126
+ // ---------- messaging ----------
127
+ // sendMessage("hi", threadID, { isGroup })
128
+ // sendMessage({ body, attachment: [path|Buffer], sticker, mentions:[{tag,id}], replyTo }, threadID, { isGroup })
129
+ api.sendMessage = (message, threadID, opts = {}) =>
130
+ enqueue(async () => {
131
+ if (!threadID) throw new Error("threadID is required");
132
+ const msg = typeof message === "string" ? { body: message } : { ...message };
133
+ if (!msg.body && !msg.attachment && !msg.sticker) throw new Error("Nothing to send");
134
+
135
+ const { otid, fields } = messageBase();
136
+ fields.body = msg.body || "";
137
+
138
+ (msg.mentions || []).forEach((m, i) => {
139
+ const offset = fields.body.indexOf(m.tag, m.fromIndex || 0);
140
+ if (offset < 0) return;
141
+ fields[`profile_xmd[${i}][id]`] = m.id;
142
+ fields[`profile_xmd[${i}][offset]`] = offset;
143
+ fields[`profile_xmd[${i}][length]`] = m.tag.length;
144
+ fields[`profile_xmd[${i}][type]`] = "p";
145
+ });
146
+
147
+ const atts = msg.attachment ? [].concat(msg.attachment) : [];
148
+ const counters = {};
149
+ for (const a of atts) {
150
+ const { type, id } = await api.uploadAttachment(a);
151
+ const n = counters[type] || 0;
152
+ counters[type] = n + 1;
153
+ fields[`${type}s[${n}]`] = id;
154
+ fields.has_attachment = true;
155
+ }
156
+
157
+ if (msg.sticker) {
158
+ fields.sticker_id = msg.sticker;
159
+ fields.has_attachment = true;
160
+ }
161
+ if (msg.replyTo) fields.replied_to_message_id = msg.replyTo;
162
+
163
+ if (opts.isGroup) {
164
+ fields.thread_fbid = threadID;
165
+ } else {
166
+ fields.other_user_fbid = threadID;
167
+ fields["specific_to_list[0]"] = "fbid:" + threadID;
168
+ fields["specific_to_list[1]"] = "fbid:" + ctx.userID;
169
+ }
170
+
171
+ const res = await call(SEND_URL, fields);
172
+ const act = res.payload && res.payload.actions && res.payload.actions[0];
173
+ return { messageID: (act && act.message_id) || otid, threadID };
174
+ });
175
+
176
+ api.unsendMessage = async (messageID) => {
177
+ await call("https://www.facebook.com/messaging/unsend_message/", { message_id: messageID });
178
+ };
179
+
180
+ // emoji = "❤" etc. Pass "" to remove the reaction
181
+ api.setMessageReaction = async (emoji, messageID) => {
182
+ const res = await ctx.http.postJson(
183
+ "https://www.facebook.com/webgraphql/mutation/",
184
+ ctx.form({
185
+ doc_id: "1491398900900362",
186
+ variables: JSON.stringify({
187
+ data: {
188
+ action: emoji ? "ADD_REACTION" : "REMOVE_REACTION",
189
+ client_mutation_id: "1",
190
+ actor_id: ctx.userID,
191
+ message_id: String(messageID),
192
+ reaction: emoji || undefined,
193
+ },
194
+ }),
195
+ dpr: 1,
196
+ })
197
+ );
198
+ if (res.error) throw new Error("setMessageReaction failed: " + (res.errorDescription || res.error));
199
+ };
200
+
201
+ api.markAsRead = async (threadID, read = true) => {
202
+ await call("https://www.facebook.com/ajax/mercury/change_read_status.php", {
203
+ [`ids[${threadID}]`]: read,
204
+ watermarkTimestamp: Date.now(),
205
+ shouldSendReadReceipt: true,
206
+ commerce_last_message_type: "non_ad",
207
+ });
208
+ };
209
+
210
+ api.sendTypingIndicator = async (threadID, isTyping = true, isGroup = false) => {
211
+ await ctx.http.postRaw(
212
+ "https://www.facebook.com/ajax/messaging/typ.php",
213
+ ctx.form({ typ: isTyping ? 1 : 0, to: isGroup ? "" : threadID, source: "mercury-chat", thread: threadID })
214
+ );
215
+ };
216
+
217
+ // ---------- users ----------
218
+ api.getUserInfo = async (userID) => {
219
+ const res = await call("https://www.facebook.com/chat/user_info/", { "ids[0]": userID });
220
+ return res.payload && res.payload.profiles ? res.payload.profiles[userID] || null : null;
221
+ };
222
+
223
+ api.getFriendsList = async () => {
224
+ const res = await call("https://www.facebook.com/chat/user_info_all", { viewer: ctx.userID });
225
+ const p = (res && res.payload) || {};
226
+ return Object.keys(p).map((id) => ({ userID: id, ...p[id] }));
227
+ };
228
+
229
+ // ---------- threads ----------
230
+ api.getThreadInfo = async (threadID) => {
231
+ const res = await call("https://www.facebook.com/ajax/mercury/thread_info.php", {
232
+ client: "mercury",
233
+ "threads[thread_fbids][0]": threadID,
234
+ });
235
+ return (res.payload && res.payload.threads && res.payload.threads[0]) || null;
236
+ };
237
+
238
+ api.getThreadList = async (limit = 20, offset = 0) => {
239
+ const res = await call("https://www.facebook.com/ajax/mercury/threadlist_info.php", {
240
+ client: "mercury",
241
+ "inbox[offset]": offset,
242
+ "inbox[limit]": limit,
243
+ "inbox[filter]": "",
244
+ });
245
+ return (res.payload && res.payload.threads) || [];
246
+ };
247
+
248
+ api.getThreadHistory = async (threadID, amount = 20, beforeTimestamp = null, isGroup = false) => {
249
+ const kind = isGroup ? "thread_fbids" : "user_ids";
250
+ const form = {
251
+ client: "mercury",
252
+ [`messages[${kind}][${threadID}][offset]`]: 0,
253
+ [`messages[${kind}][${threadID}][limit]`]: amount,
254
+ };
255
+ if (beforeTimestamp) form[`messages[${kind}][${threadID}][timestamp]`] = beforeTimestamp;
256
+ const res = await call("https://www.facebook.com/ajax/mercury/thread_info.php", form);
257
+ return (res.payload && res.payload.actions) || [];
258
+ };
259
+
260
+ // ---------- group management ----------
261
+ api.changeNickname = async (nickname, threadID, participantID) => {
262
+ await call("https://www.facebook.com/messaging/save_thread_nickname/", {
263
+ nickname,
264
+ participant_id: participantID,
265
+ thread_or_other_fbid: threadID,
266
+ });
267
+ };
268
+
269
+ api.setTitle = (newTitle, threadID) =>
270
+ enqueue(async () => {
271
+ const { fields } = messageBase();
272
+ Object.assign(fields, {
273
+ action_type: "ma-type:log-message",
274
+ log_message_type: "log:thread-name",
275
+ thread_name: newTitle,
276
+ thread_fbid: threadID,
277
+ thread_id: threadID,
278
+ });
279
+ await call(SEND_URL, fields);
280
+ });
281
+
282
+ api.addUserToGroup = (userIDs, threadID) =>
283
+ enqueue(async () => {
284
+ const { fields } = messageBase();
285
+ Object.assign(fields, {
286
+ action_type: "ma-type:log-message",
287
+ log_message_type: "log:subscribe",
288
+ thread_fbid: threadID,
289
+ });
290
+ [].concat(userIDs).forEach((id, i) => {
291
+ fields[`log_message_data[added_participants][${i}]`] = "fbid:" + id;
292
+ });
293
+ await call(SEND_URL, fields);
294
+ });
295
+
296
+ api.removeUserFromGroup = async (userID, threadID) => {
297
+ await call("https://www.facebook.com/chat/remove_participants/", { uid: userID, tid: threadID });
298
+ };
299
+
300
+ // Create a brand-new group chat. Returns the new threadID.
301
+ // Facebook requires at least 2 other participants to create a *group* thread;
302
+ // with exactly 1 participant it will just be a normal 1-to-1 thread.
303
+ api.createGroup = (userIDs, title) =>
304
+ enqueue(async () => {
305
+ const ids = [].concat(userIDs);
306
+ if (ids.length < 2) throw new Error("createGroup needs at least 2 other participants");
307
+
308
+ const { otid, fields } = messageBase();
309
+ fields.body = "";
310
+ ids.forEach((id, i) => {
311
+ fields[`specific_to_list[${i}]`] = "fbid:" + id;
312
+ });
313
+ fields[`specific_to_list[${ids.length}]`] = "fbid:" + ctx.userID;
314
+ fields.client_thread_id = "root:" + otid;
315
+ if (title) fields.thread_name = title;
316
+
317
+ const res = await call(SEND_URL, fields);
318
+ const act = res.payload && res.payload.actions && res.payload.actions[0];
319
+ const threadID = act && (act.thread_fbid || act.threadid);
320
+ if (!threadID) throw new Error("createGroup: could not read new thread id from response");
321
+ return String(threadID);
322
+ });
323
+
324
+ // role: "admin" grants, "member" revokes
325
+ api.changeAdminStatus = async (threadID, userID, makeAdmin = true) => {
326
+ await call("https://www.facebook.com/messaging/save_admins/", {
327
+ thread_fbid: threadID,
328
+ [`admin_ids[${makeAdmin ? "add" : "remove"}][0]`]: userID,
329
+ });
330
+ };
331
+
332
+ // hex color like "#0084ff", or one of Messenger's named theme ids
333
+ api.changeThreadColor = async (threadID, color) => {
334
+ await call("https://www.facebook.com/messaging/save_thread_color/", {
335
+ thread_fbid: threadID,
336
+ color,
337
+ });
338
+ };
339
+
340
+ // emoji = single emoji character used as the thread's quick-reaction/icon
341
+ api.changeThreadEmoji = async (threadID, emoji) => {
342
+ await call("https://www.facebook.com/messaging/save_thread_emoji/", {
343
+ thread_fbid: threadID,
344
+ emoji_choice: emoji,
345
+ });
346
+ };
347
+
348
+ api.muteThread = async (threadID, muteSeconds = -1) => {
349
+ // muteSeconds: -1 = mute indefinitely, 0 = unmute, N = mute for N seconds
350
+ await call("https://www.facebook.com/ajax/mercury/change_mute_thread.php", {
351
+ thread_fbid: threadID,
352
+ mute_settings: muteSeconds,
353
+ });
354
+ };
355
+
356
+ api.pinMessage = async (threadID, messageID, pinned = true) => {
357
+ const res = await ctx.http.postJson(
358
+ "https://www.facebook.com/webgraphql/mutation/",
359
+ ctx.form({
360
+ doc_id: "4260452710709500",
361
+ variables: JSON.stringify({
362
+ input: {
363
+ thread_key: threadID,
364
+ message_id: messageID,
365
+ pinned_state: pinned ? "PINNED" : "UNPINNED",
366
+ actor_id: ctx.userID,
367
+ client_mutation_id: "1",
368
+ },
369
+ }),
370
+ })
371
+ );
372
+ if (res.error) throw new Error("pinMessage failed: " + (res.errorDescription || res.error));
373
+ };
374
+
375
+ // options: array of strings; multiSelect allows more than one vote per person
376
+ api.createPoll = async (threadID, question, options, multiSelect = false) => {
377
+ const form = ctx.form({
378
+ thread_fbid: threadID,
379
+ question_text: question,
380
+ group_poll_allow_multiselect: multiSelect,
381
+ });
382
+ options.forEach((opt, i) => {
383
+ form[`options_text_array[${i}]`] = opt;
384
+ });
385
+ const res = await call("https://www.facebook.com/messaging/group_polling/create_poll/", form);
386
+ return (res.payload && res.payload.poll_id) || null;
387
+ };
388
+
389
+ api.voteInPoll = async (pollID, optionIDs) => {
390
+ const form = ctx.form({ question_id: pollID });
391
+ [].concat(optionIDs).forEach((id, i) => {
392
+ form[`option_ids[${i}]`] = id;
393
+ });
394
+ await call("https://www.facebook.com/messaging/group_polling/update_vote/", form);
395
+ };
396
+
397
+ // ---------- privacy ----------
398
+ api.blockUser = async (userID) => {
399
+ await call("https://www.facebook.com/ajax/settings/blocking/block.php", { uid: userID });
400
+ };
401
+
402
+ api.unblockUser = async (userID) => {
403
+ await call("https://www.facebook.com/ajax/settings/blocking/unblock.php", { uid: userID });
404
+ };
405
+
406
+ // ---------- search ----------
407
+ // Search threads by name (people or group titles)
408
+ api.searchThreads = async (query, limit = 10) => {
409
+ const res = await call("https://www.facebook.com/ajax/typeahead/search.php", {
410
+ filter: "user,group,pages",
411
+ value: query,
412
+ viewer: ctx.userID,
413
+ rsp: "search",
414
+ context: "search",
415
+ limit,
416
+ });
417
+ return (res.payload && (res.payload.entries || res.payload)) || [];
418
+ };
419
+
420
+ // Full-text search within one thread's message history.
421
+ // Facebook's search backend for this shifts around; treat the shape of
422
+ // the response as unstable and verify against your own account.
423
+ api.searchMessages = async (threadID, query, limit = 20) => {
424
+ const res = await call("https://www.facebook.com/ajax/mercury/search_snippets.php", {
425
+ query,
426
+ snippetLimit: limit,
427
+ "identifiers[0]": threadID,
428
+ });
429
+ return (res.payload && res.payload.snippets && res.payload.snippets[threadID]) || [];
430
+ };
431
+
432
+ // ---------- media (large/chunked upload) ----------
433
+ // For files too large for a single multipart POST (long videos, large
434
+ // archives). Splits into chunks and uploads sequentially, then finalizes.
435
+ // Chunk size and the resumable-upload endpoint shape are the parts of
436
+ // Facebook's upload pipeline most likely to have moved; if this fails,
437
+ // fall back to api.uploadAttachment for files under ~25MB.
438
+ api.uploadLargeAttachment = async (att, { chunkSizeBytes = 4 * 1024 * 1024 } = {}) => {
439
+ const file = await toFile(att);
440
+ const total = file.buffer.length;
441
+ if (total <= chunkSizeBytes) return api.uploadAttachment(att);
442
+
443
+ const startRes = await ctx.http.postJson(
444
+ "https://www.facebook.com/ajax/mercury/upload_resumable_start.php",
445
+ ctx.form({ file_name: file.filename, file_size: total, content_type: file.contentType })
446
+ );
447
+ if (startRes.error) throw new Error("resumable start failed: " + (startRes.errorDescription || startRes.error));
448
+ const uploadSessionId = startRes.payload && startRes.payload.upload_session_id;
449
+ if (!uploadSessionId) throw new Error("resumable start: no upload_session_id in response");
450
+
451
+ for (let offset = 0; offset < total; offset += chunkSizeBytes) {
452
+ const chunk = file.buffer.subarray(offset, Math.min(offset + chunkSizeBytes, total));
453
+ const res = await ctx.http.postMultipart(
454
+ "https://upload.facebook.com/ajax/mercury/upload_resumable_chunk.php",
455
+ ctx.form({ upload_session_id: uploadSessionId, offset }),
456
+ { field: "chunk", buffer: chunk, filename: file.filename, contentType: file.contentType }
457
+ );
458
+ if (res.error) throw new Error(`chunk upload failed at offset ${offset}: ` + (res.errorDescription || res.error));
459
+ }
460
+
461
+ const finishRes = await ctx.http.postJson(
462
+ "https://www.facebook.com/ajax/mercury/upload_resumable_finish.php",
463
+ ctx.form({ upload_session_id: uploadSessionId })
464
+ );
465
+ if (finishRes.error) throw new Error("resumable finish failed: " + (finishRes.errorDescription || finishRes.error));
466
+ const meta = finishRes.payload && finishRes.payload.metadata && finishRes.payload.metadata[0];
467
+ if (!meta) throw new Error("resumable finish: empty metadata");
468
+ const key = Object.keys(meta).find((k) => /_id$/.test(k));
469
+ return { type: key, id: meta[key] };
470
+ };
471
+
472
+ // ---------- listening ----------
473
+ // const stop = api.listen((err, event) => {...})
474
+ api.listen = (callback) => createListener(ctx, api, callback);
475
+ api.listenMqtt = api.listen; // FCA-style alias
476
+
477
+ return api;
478
+ }
479
+
480
+ module.exports = { buildApi };
package/src/http.js ADDED
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ const UA =
4
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
5
+
6
+ class Http {
7
+ constructor(appState) {
8
+ this.jar = new Map();
9
+ for (const c of appState) {
10
+ const name = c.key || c.name;
11
+ if (name && c.value !== undefined) this.jar.set(name, c.value);
12
+ }
13
+ }
14
+
15
+ get userAgent() {
16
+ return UA;
17
+ }
18
+
19
+ cookieHeader() {
20
+ return [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
21
+ }
22
+
23
+ exportAppState() {
24
+ return [...this.jar].map(([key, value]) => ({
25
+ key,
26
+ value,
27
+ domain: ".facebook.com",
28
+ path: "/",
29
+ }));
30
+ }
31
+
32
+ _absorb(res) {
33
+ const list = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : [];
34
+ for (const line of list) {
35
+ const kv = line.split(";")[0];
36
+ const i = kv.indexOf("=");
37
+ if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
38
+ }
39
+ }
40
+
41
+ _headers(extra = {}) {
42
+ return {
43
+ "User-Agent": UA,
44
+ Cookie: this.cookieHeader(),
45
+ Origin: "https://www.facebook.com",
46
+ Referer: "https://www.facebook.com/",
47
+ "Accept-Language": "en-US,en;q=0.9",
48
+ ...extra,
49
+ };
50
+ }
51
+
52
+ _parse(text) {
53
+ try {
54
+ // Facebook prefixes JSON responses with "for (;;);"
55
+ return JSON.parse(text.replace(/^for \(;;\);/, ""));
56
+ } catch (e) {
57
+ const err = new Error("Non-JSON response from Facebook (session expired, blocked, or endpoint changed)");
58
+ err.raw = text.slice(0, 300);
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ async getText(url) {
64
+ const res = await fetch(url, { headers: this._headers(), redirect: "follow" });
65
+ this._absorb(res);
66
+ return res.text();
67
+ }
68
+
69
+ async postRaw(url, form) {
70
+ const res = await fetch(url, {
71
+ method: "POST",
72
+ headers: this._headers({ "Content-Type": "application/x-www-form-urlencoded" }),
73
+ body: new URLSearchParams(form).toString(),
74
+ });
75
+ this._absorb(res);
76
+ return res.text();
77
+ }
78
+
79
+ async postJson(url, form) {
80
+ return this._parse(await this.postRaw(url, form));
81
+ }
82
+
83
+ // file: { field, buffer, filename, contentType }
84
+ async postMultipart(url, form, file) {
85
+ const fd = new FormData();
86
+ for (const [k, v] of Object.entries(form)) fd.append(k, String(v));
87
+ fd.append(file.field, new Blob([file.buffer], { type: file.contentType }), file.filename);
88
+ const res = await fetch(url, { method: "POST", headers: this._headers(), body: fd });
89
+ this._absorb(res);
90
+ return this._parse(await res.text());
91
+ }
92
+ }
93
+
94
+ module.exports = Http;
package/src/listen.js ADDED
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+
3
+ const mqtt = require("mqtt");
4
+
5
+ const TOPICS = [
6
+ "/t_ms", "/thread_typing", "/orca_typing_notifications", "/orca_presence", "/legacy_web",
7
+ "/br_sr", "/sr_res", "/webrtc", "/onevc", "/notify_disconnect", "/inbox", "/mercury",
8
+ "/messaging_events", "/orca_message_notifications", "/pp", "/webrtc_response",
9
+ ];
10
+
11
+ // Initial sync sequence id via GraphQL.
12
+ // The doc_id below is an internal Facebook value and may be outdated.
13
+ async function fetchSeqID(ctx) {
14
+ const form = ctx.form({
15
+ queries: JSON.stringify({
16
+ o0: {
17
+ doc_id: "3336396659757871",
18
+ query_params: { limit: 1, before: null, tags: ["INBOX"], includeDeliveryReceipts: false, includeSeqID: true },
19
+ },
20
+ }),
21
+ batch_name: "MessengerGraphQLThreadlistFetcher",
22
+ });
23
+ const text = await ctx.http.postRaw("https://www.facebook.com/api/graphqlbatch/", form);
24
+ const first = text.split("\n")[0].replace(/^for \(;;\);/, "");
25
+ let json;
26
+ try {
27
+ json = JSON.parse(first);
28
+ } catch (e) {
29
+ throw new Error("Could not parse sequence id response (session invalid?)");
30
+ }
31
+ const seq =
32
+ json && json.o0 && json.o0.data && json.o0.data.viewer && json.o0.data.viewer.message_threads &&
33
+ json.o0.data.viewer.message_threads.sync_sequence_id;
34
+ if (!seq) throw new Error("Could not read sync_sequence_id (doc_id outdated or session invalid)");
35
+ return seq;
36
+ }
37
+
38
+ const tid = (key) => String((key && (key.threadFbId || key.otherUserFbId)) || "");
39
+ const isGroupKey = (key) => !!(key && key.threadFbId);
40
+
41
+ // Returns an array of normalized events for one delta
42
+ function parseDelta(delta, api) {
43
+ const out = [];
44
+ const cls = delta.class;
45
+ const md = delta.messageMetadata;
46
+
47
+ if (cls === "NewMessage" && md) {
48
+ const threadID = tid(md.threadKey);
49
+ const isGroup = isGroupKey(md.threadKey);
50
+ out.push({
51
+ type: "message",
52
+ threadID,
53
+ senderID: String(md.actorFbId),
54
+ messageID: md.messageId,
55
+ body: delta.body || "",
56
+ timestamp: md.timestamp,
57
+ isGroup,
58
+ attachments: delta.attachments || [],
59
+ replyToMessageID: delta.messageReply && delta.messageReply.replyToMessageId
60
+ ? delta.messageReply.replyToMessageId.id
61
+ : null,
62
+ reply: (text) => api.sendMessage(text, threadID, { isGroup }),
63
+ });
64
+ } else if (cls === "ParticipantsAddedToGroupThread" && md) {
65
+ out.push({
66
+ type: "event",
67
+ logMessageType: "log:subscribe",
68
+ threadID: tid(md.threadKey),
69
+ authorID: String(md.actorFbId),
70
+ addedParticipants: (delta.addedParticipants || []).map((p) => String(p.userFbId)),
71
+ isGroup: true,
72
+ });
73
+ } else if (cls === "ParticipantLeftGroupThread" && md) {
74
+ out.push({
75
+ type: "event",
76
+ logMessageType: "log:unsubscribe",
77
+ threadID: tid(md.threadKey),
78
+ authorID: String(md.actorFbId),
79
+ leftParticipantFbId: String(delta.leftParticipantFbId),
80
+ isGroup: true,
81
+ });
82
+ } else if (cls === "ThreadName" && md) {
83
+ out.push({
84
+ type: "event",
85
+ logMessageType: "log:thread-name",
86
+ threadID: tid(md.threadKey),
87
+ authorID: String(md.actorFbId),
88
+ name: delta.name,
89
+ isGroup: true,
90
+ });
91
+ } else if (cls === "ReadReceipt") {
92
+ out.push({
93
+ type: "read_receipt",
94
+ threadID: tid(delta.threadKey),
95
+ readerID: String(delta.actorFbId),
96
+ time: delta.actionTimestampMs,
97
+ });
98
+ } else if (cls === "ClientPayload" && Array.isArray(delta.payload)) {
99
+ // Reactions and unsends arrive as a byte-array JSON payload
100
+ let inner;
101
+ try {
102
+ inner = JSON.parse(Buffer.from(delta.payload).toString("utf8"));
103
+ } catch (e) {
104
+ return out;
105
+ }
106
+ for (const d of inner.deltas || []) {
107
+ if (d.deltaMessageReaction) {
108
+ const r = d.deltaMessageReaction;
109
+ out.push({
110
+ type: "message_reaction",
111
+ threadID: tid(r.threadKey),
112
+ messageID: r.messageId,
113
+ senderID: String(r.senderId), // author of the reacted message
114
+ userID: String(r.userId), // person who reacted
115
+ reaction: r.reaction,
116
+ action: r.action === 1 ? "remove" : "add",
117
+ });
118
+ }
119
+ if (d.deltaRecallMessageData) {
120
+ const r = d.deltaRecallMessageData;
121
+ out.push({
122
+ type: "message_unsend",
123
+ threadID: tid(r.threadKey),
124
+ messageID: r.messageID,
125
+ senderID: String(r.senderID),
126
+ deletionTimestamp: r.deletionTimestamp,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function createListener(ctx, api, callback) {
135
+ let client = null;
136
+ let stopped = false;
137
+ let retry = 0;
138
+ let timer = null;
139
+
140
+ const emit = (event) => {
141
+ const o = ctx.options;
142
+ if (event.type === "message") {
143
+ if (!o.selfListen && event.senderID === ctx.userID) return;
144
+ } else if (event.type !== "system" && !o.listenEvents) {
145
+ return;
146
+ }
147
+ callback(null, event);
148
+ };
149
+
150
+ function scheduleReconnect() {
151
+ if (stopped || !ctx.options.autoReconnect || timer) return;
152
+
153
+ if (client) {
154
+ client.removeAllListeners();
155
+ client.on("error", () => {});
156
+ client.end(true);
157
+ client = null;
158
+ }
159
+
160
+ retry += 1;
161
+ if (retry > ctx.options.maxRetries) {
162
+ callback(new Error("Giving up after too many reconnect attempts"));
163
+ return;
164
+ }
165
+
166
+ const delay = Math.min(30000, 1000 * 2 ** retry);
167
+ emit({ type: "system", state: "reconnecting", attempt: retry, delayMs: delay });
168
+ timer = setTimeout(async () => {
169
+ timer = null;
170
+ try {
171
+ await ctx.refreshTokens();
172
+ } catch (e) {
173
+ /* ignore; connect() will report */
174
+ }
175
+ connect();
176
+ }, delay);
177
+ }
178
+
179
+ async function connect() {
180
+ if (stopped) return;
181
+ try {
182
+ if (!ctx.lastSeqId) ctx.lastSeqId = await fetchSeqID(ctx);
183
+
184
+ const sessionID = Math.floor(Math.random() * 9007199254740991) + 1;
185
+ const username = {
186
+ u: ctx.userID, s: sessionID, chat_on: true, fg: false, d: ctx.clientID, ct: "websocket",
187
+ aid: "219994525426954", aids: null, mqtt_sid: "", cp: 3, ecp: 10, st: [], pm: [], dc: "",
188
+ no_auto_fg: true, gas: null, pack: [], p: null, php_override: "",
189
+ };
190
+ const url = `wss://edge-chat.facebook.com/chat?region=${ctx.region}&sid=${sessionID}&cid=${ctx.clientID}`;
191
+
192
+ client = mqtt.connect(url, {
193
+ clientId: "mqttwsclient",
194
+ protocolId: "MQIsdp",
195
+ protocolVersion: 3,
196
+ username: JSON.stringify(username),
197
+ clean: true,
198
+ keepalive: 10,
199
+ reschedulePings: false,
200
+ reconnectPeriod: 0, // we handle reconnects ourselves
201
+ wsOptions: {
202
+ headers: {
203
+ Cookie: ctx.http.cookieHeader(),
204
+ Origin: "https://www.facebook.com",
205
+ "User-Agent": ctx.http.userAgent,
206
+ Referer: "https://www.facebook.com/",
207
+ Host: "edge-chat.facebook.com",
208
+ },
209
+ origin: "https://www.facebook.com",
210
+ protocolVersion: 13,
211
+ },
212
+ });
213
+
214
+ client.on("connect", () => {
215
+ retry = 0;
216
+ TOPICS.forEach((t) => client.subscribe(t));
217
+
218
+ const queue = {
219
+ sync_api_version: 10,
220
+ max_deltas_able_to_process: 1000,
221
+ delta_batch_size: 500,
222
+ encoding: "JSON",
223
+ entity_fbid: ctx.userID,
224
+ };
225
+ let topic;
226
+ if (ctx.syncToken) {
227
+ topic = "/messenger_sync_get_diffs";
228
+ queue.last_seq_id = ctx.lastSeqId;
229
+ queue.sync_token = ctx.syncToken;
230
+ } else {
231
+ topic = "/messenger_sync_create_queue";
232
+ queue.initial_titles_cursor = null;
233
+ queue.device_params = null;
234
+ queue.last_seq_id = ctx.lastSeqId;
235
+ }
236
+ client.publish(topic, JSON.stringify(queue), { qos: 1, retain: false });
237
+ emit({ type: "system", state: "connected" });
238
+ });
239
+
240
+ client.on("message", (topic, payload) => {
241
+ let msg;
242
+ try {
243
+ msg = JSON.parse(payload.toString());
244
+ } catch (e) {
245
+ return;
246
+ }
247
+
248
+ if (topic === "/thread_typing" || topic === "/orca_typing_notifications") {
249
+ if (msg.type === "typ") {
250
+ emit({
251
+ type: "typ",
252
+ isTyping: !!msg.state,
253
+ from: String(msg.sender_fbid),
254
+ threadID: String(msg.thread || msg.sender_fbid),
255
+ });
256
+ }
257
+ return;
258
+ }
259
+
260
+ if (topic !== "/t_ms") return;
261
+
262
+ if (msg.firstDeltaSeqId && msg.syncToken) {
263
+ ctx.lastSeqId = msg.firstDeltaSeqId;
264
+ ctx.syncToken = msg.syncToken;
265
+ }
266
+ if (msg.lastIssuedSeqId) ctx.lastSeqId = msg.lastIssuedSeqId;
267
+
268
+ for (const delta of msg.deltas || []) {
269
+ for (const event of parseDelta(delta, api)) emit(event);
270
+ }
271
+ });
272
+
273
+ client.on("error", (err) => callback(err));
274
+ client.on("close", scheduleReconnect);
275
+ } catch (err) {
276
+ callback(err);
277
+ scheduleReconnect();
278
+ }
279
+ }
280
+
281
+ connect();
282
+
283
+ return function stop() {
284
+ stopped = true;
285
+ if (timer) clearTimeout(timer);
286
+ if (client) client.end(true);
287
+ };
288
+ }
289
+
290
+ module.exports = { createListener };