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.
- package/.github/workflows/main.yml +28 -0
- package/README.md +125 -0
- package/index.js +97 -0
- package/package.json +10 -0
- package/src/api.js +480 -0
- package/src/http.js +94 -0
- package/src/listen.js +290 -0
|
@@ -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
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 };
|