@eryxenx/insta-chat 2.0.11 → 2.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/CHANGELOG.md +54 -0
- package/DOCS.md +53 -11
- package/README.md +83 -3
- package/index.d.ts +103 -0
- package/index.js +6 -0
- package/package.json +2 -2
- package/src/api/realtime/listen.js +3 -1
- package/src/api/users/getProfileBio.js +33 -0
- package/src/api/users/getProfilePictureV2.js +54 -0
- package/src/api/users/getSelfInfo.js +27 -0
- package/src/api/users/getUserFriendship.js +42 -0
- package/src/api/users/getUserID.js +31 -0
- package/src/api/users/getUserInfoV2.js +62 -0
- package/src/api/users/getUserProfile.js +26 -0
- package/src/api.js +116 -2
- package/src/app/InstaClient.js +23 -0
- package/src/app/createFcaClient.js +13 -1
- package/src/constants.js +10 -1
- package/src/methods/inbox.js +82 -1
- package/src/methods/user.js +180 -4
- package/src/profileParse.js +250 -0
- package/src/realtime/bridge.js +76 -0
- package/src/realtime/client.js +376 -0
- package/src/realtime/listener.js +86 -0
- package/src/realtime/mqttot.js +609 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* insta-chat - Unofficial Instagram Chat API
|
|
3
|
+
* Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
|
|
4
|
+
* Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
|
|
5
|
+
* Original author credit must be retained in all copies and derivatives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Parsers for the two profile sources this package can read with a web session:
|
|
9
|
+
//
|
|
10
|
+
// 1. /api/v1/users/web_profile_info/ - the canonical profile JSON
|
|
11
|
+
// (`data.user` with edge_followed_by.count, biography, is_verified, ...).
|
|
12
|
+
// 2. the profile page HTML - www.instagram.com/<username>/ embeds a
|
|
13
|
+
// `["PolarisViewer",[],{"data":{...}}]` blob with biography, full_name,
|
|
14
|
+
// profile_pic_url_hd and the account flags, but no follower counts.
|
|
15
|
+
//
|
|
16
|
+
// Both are normalized to one rich object so callers never branch on the source.
|
|
17
|
+
// The /api/v1/users/<id>/info/ REST path is deliberately not used: for a web
|
|
18
|
+
// session Instagram 302s it to the login page (which the HTTP layer reads as an
|
|
19
|
+
// expired session) or 429s it.
|
|
20
|
+
|
|
21
|
+
// Walks a JSON object literal starting at the first `{` at/after `from`,
|
|
22
|
+
// honouring strings and escapes, and returns the parsed value (or null).
|
|
23
|
+
function parseJsonAt(text, from) {
|
|
24
|
+
let i = text.indexOf("{", from);
|
|
25
|
+
if (i < 0) return null;
|
|
26
|
+
const start = i;
|
|
27
|
+
let depth = 0;
|
|
28
|
+
let inStr = false;
|
|
29
|
+
let esc = false;
|
|
30
|
+
for (; i < text.length; i++) {
|
|
31
|
+
const c = text[i];
|
|
32
|
+
if (inStr) {
|
|
33
|
+
if (esc) esc = false;
|
|
34
|
+
else if (c === "\\") esc = true;
|
|
35
|
+
else if (c === '"') inStr = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '"') inStr = true;
|
|
39
|
+
else if (c === "{") depth++;
|
|
40
|
+
else if (c === "}") {
|
|
41
|
+
depth--;
|
|
42
|
+
if (depth === 0) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
45
|
+
} catch (_) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function countOf(edge) {
|
|
55
|
+
const n = edge && edge.count;
|
|
56
|
+
return Number.isFinite(Number(n)) ? Number(n) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Accepts either a Relay field-count edge (`{ count: n }`) or a plain number.
|
|
60
|
+
function countOrEdge(v) {
|
|
61
|
+
if (v == null) return null;
|
|
62
|
+
if (typeof v === "object") return countOf(v);
|
|
63
|
+
return Number.isFinite(Number(v)) ? Number(v) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Maps a web_profile_info `data.user` (REST) to the normalized shape.
|
|
67
|
+
function normalizeWebProfileInfo(user) {
|
|
68
|
+
if (!user || typeof user !== "object") return null;
|
|
69
|
+
return normalize({
|
|
70
|
+
id: user.id || user.pk,
|
|
71
|
+
username: user.username,
|
|
72
|
+
fullName: user.full_name,
|
|
73
|
+
biography: user.biography,
|
|
74
|
+
externalUrl: user.external_url,
|
|
75
|
+
category: user.category_name || user.category,
|
|
76
|
+
isVerified: user.is_verified,
|
|
77
|
+
isPrivate: user.is_private,
|
|
78
|
+
isBusinessAccount: user.is_business_account,
|
|
79
|
+
isProfessionalAccount: user.is_professional_account,
|
|
80
|
+
profilePicUrl: user.profile_pic_url,
|
|
81
|
+
profilePicUrlHd: user.profile_pic_url_hd || user.profile_pic_url,
|
|
82
|
+
fbid: user.fbid,
|
|
83
|
+
followerCount: countOf(user.edge_followed_by),
|
|
84
|
+
followingCount: countOf(user.edge_follow),
|
|
85
|
+
mediaCount: countOf(user.edge_owner_to_timeline_media),
|
|
86
|
+
highlightReelCount: user.highlight_reel_count,
|
|
87
|
+
businessEmail: user.business_email,
|
|
88
|
+
businessPhone: user.business_phone_number,
|
|
89
|
+
publicEmail: user.public_email,
|
|
90
|
+
publicPhone: user.public_phone_number,
|
|
91
|
+
hasProfilePic: user.has_profile_pic,
|
|
92
|
+
raw: user
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Maps the GraphQL profile response (`data.user` from PolarisProfilePageContent-
|
|
97
|
+
// Query / PolarisUserRootQuery) to the normalized shape. The field names are the
|
|
98
|
+
// Relay ones, and the counts come back either as an edge or as a plain number,
|
|
99
|
+
// so both are accepted.
|
|
100
|
+
function normalizeGraphqlUser(user) {
|
|
101
|
+
if (!user || typeof user !== "object") return null;
|
|
102
|
+
const follower = user.follower_count ?? user.edge_followed_by;
|
|
103
|
+
const following = user.following_count ?? user.edge_follow;
|
|
104
|
+
const media = user.media_count ?? user.edge_owner_to_timeline_media;
|
|
105
|
+
return normalize({
|
|
106
|
+
id: user.id || user.pk || user.strong_id__,
|
|
107
|
+
username: user.username,
|
|
108
|
+
fullName: user.full_name,
|
|
109
|
+
biography: user.biography,
|
|
110
|
+
externalUrl: user.external_url || user.bio_links?.[0]?.url,
|
|
111
|
+
category: user.category_name || user.category,
|
|
112
|
+
isVerified: user.is_verified,
|
|
113
|
+
isPrivate: user.is_private,
|
|
114
|
+
isBusinessAccount: user.is_business_account,
|
|
115
|
+
isProfessionalAccount: user.is_professional_account,
|
|
116
|
+
profilePicUrl: user.profile_pic_url,
|
|
117
|
+
profilePicUrlHd: user.hd_profile_pic_url_info?.url || user.profile_pic_url_hd || user.profile_pic_url,
|
|
118
|
+
fbid: user.fbid,
|
|
119
|
+
followerCount: countOrEdge(follower),
|
|
120
|
+
followingCount: countOrEdge(following),
|
|
121
|
+
mediaCount: countOrEdge(media),
|
|
122
|
+
businessEmail: user.business_email,
|
|
123
|
+
businessPhone: user.business_phone_number,
|
|
124
|
+
publicEmail: user.public_email,
|
|
125
|
+
publicPhone: user.public_phone_number,
|
|
126
|
+
hasProfilePic: user.has_profile_pic ?? (!!user.profile_pic_url),
|
|
127
|
+
raw: user
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Maps the HTML-embedded PolarisViewer `data` to the normalized shape.
|
|
132
|
+
function normalizePolarisViewer(data) {
|
|
133
|
+
if (!data || typeof data !== "object" || !data.username) return null;
|
|
134
|
+
return normalize({
|
|
135
|
+
id: data.id,
|
|
136
|
+
username: data.username,
|
|
137
|
+
fullName: data.full_name,
|
|
138
|
+
biography: data.biography,
|
|
139
|
+
externalUrl: data.external_url,
|
|
140
|
+
category: data.category_name,
|
|
141
|
+
isVerified: data.is_verified,
|
|
142
|
+
isPrivate: data.is_private,
|
|
143
|
+
isBusinessAccount: data.is_business_account,
|
|
144
|
+
isProfessionalAccount: data.is_professional_account,
|
|
145
|
+
profilePicUrl: data.profile_pic_url,
|
|
146
|
+
profilePicUrlHd: data.profile_pic_url_hd || data.profile_pic_url,
|
|
147
|
+
fbid: data.fbid,
|
|
148
|
+
hasProfilePic: data.has_profile_pic,
|
|
149
|
+
raw: data
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Maps an inbox/thread introspection user record (id, username, full_name,
|
|
154
|
+
// profile_pic_url, is_verified, interop_messaging_user_fbid) to the normalized
|
|
155
|
+
// shape. This is the data the DM GraphQL surface reliably returns for the
|
|
156
|
+
// people a bot actually talks to, when the profile REST paths rate-limit.
|
|
157
|
+
function normalizeInboxUser(u) {
|
|
158
|
+
if (!u || typeof u !== "object") return null;
|
|
159
|
+
const username = u.username || null;
|
|
160
|
+
const id = u.id || u.pk || u.interop_messaging_user_fbid || null;
|
|
161
|
+
if (!username && !id) return null;
|
|
162
|
+
return normalize({
|
|
163
|
+
id,
|
|
164
|
+
username,
|
|
165
|
+
fullName: u.full_name,
|
|
166
|
+
profilePicUrl: u.profile_pic_url,
|
|
167
|
+
profilePicUrlHd: u.profile_pic_url_hd || u.profile_pic_url,
|
|
168
|
+
isVerified: u.is_verified,
|
|
169
|
+
isPrivate: u.is_private,
|
|
170
|
+
fbid: u.interop_messaging_user_fbid,
|
|
171
|
+
raw: u
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pulls the profile out of a profile page's HTML. Returns null when the page is
|
|
176
|
+
// a login wall or carries no profile blob. `expectUsername`, when given, guards
|
|
177
|
+
// against the SPA shell embedding the *viewer's* profile for a page Instagram
|
|
178
|
+
// did not server-render: the blob is only trusted when it is the requested user.
|
|
179
|
+
function parseProfilePage(html, expectUsername) {
|
|
180
|
+
const text = typeof html === "string" ? html : "";
|
|
181
|
+
if (!text || /accounts\/login/i.test(text.slice(0, 2000))) return null;
|
|
182
|
+
for (const marker of ['"PolarisViewer",[],', '"PolarisProfilePageContentQuery"']) {
|
|
183
|
+
const at = text.indexOf(marker);
|
|
184
|
+
if (at < 0) continue;
|
|
185
|
+
const obj = parseJsonAt(text, at + marker.length);
|
|
186
|
+
const data = obj && (obj.data || obj);
|
|
187
|
+
const profile = normalizePolarisViewer(data);
|
|
188
|
+
if (!profile) continue;
|
|
189
|
+
if (expectUsername && String(profile.username).toLowerCase() !== String(expectUsername).toLowerCase()) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
return profile;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The single normalized profile shape every source maps to. Fields a source
|
|
198
|
+
// cannot supply stay null, so a caller can tell "unknown" from "false".
|
|
199
|
+
function normalize(p) {
|
|
200
|
+
if (!p) return null;
|
|
201
|
+
const id = p.id != null ? String(p.id) : null;
|
|
202
|
+
return {
|
|
203
|
+
id,
|
|
204
|
+
pk: id,
|
|
205
|
+
username: p.username || null,
|
|
206
|
+
fullName: p.fullName || null,
|
|
207
|
+
biography: p.biography || "",
|
|
208
|
+
externalUrl: p.externalUrl || null,
|
|
209
|
+
category: p.category || null,
|
|
210
|
+
isVerified: p.isVerified === true,
|
|
211
|
+
isPrivate: p.isPrivate === true,
|
|
212
|
+
isBusinessAccount: p.isBusinessAccount === true,
|
|
213
|
+
isProfessionalAccount: p.isProfessionalAccount === true,
|
|
214
|
+
hasProfilePic: p.hasProfilePic === true,
|
|
215
|
+
profilePicUrl: p.profilePicUrl || null,
|
|
216
|
+
profilePicUrlHd: p.profilePicUrlHd || p.profilePicUrl || null,
|
|
217
|
+
fbid: p.fbid != null ? String(p.fbid) : null,
|
|
218
|
+
followerCount: p.followerCount != null ? p.followerCount : null,
|
|
219
|
+
followingCount: p.followingCount != null ? p.followingCount : null,
|
|
220
|
+
mediaCount: p.mediaCount != null ? p.mediaCount : null,
|
|
221
|
+
highlightReelCount: p.highlightReelCount != null ? p.highlightReelCount : null,
|
|
222
|
+
businessEmail: p.businessEmail || null,
|
|
223
|
+
businessPhone: p.businessPhone || null,
|
|
224
|
+
publicEmail: p.publicEmail || null,
|
|
225
|
+
publicPhone: p.publicPhone || null,
|
|
226
|
+
raw: p.raw || null
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Instagram's CDN resizes a profile picture from the URL's stp= parameter. This
|
|
231
|
+
// returns the same picture at a requested square size, so callers get a crisp
|
|
232
|
+
// avatar without a second API call.
|
|
233
|
+
function profilePictureAtSize(url, size) {
|
|
234
|
+
if (!url || !size) return url;
|
|
235
|
+
const px = Math.max(1, Math.round(Number(size) || 0));
|
|
236
|
+
if (!px) return url;
|
|
237
|
+
const stp = `dst-jpg_s${px}x${px}_tt6`;
|
|
238
|
+
if (/[?&]stp=[^&]+/.test(url)) return url.replace(/([?&])stp=[^&]+/, `$1stp=${stp}`);
|
|
239
|
+
return `${url}${url.includes("?") ? "&" : "?"}stp=${stp}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
parseProfilePage,
|
|
244
|
+
normalizeWebProfileInfo,
|
|
245
|
+
normalizeGraphqlUser,
|
|
246
|
+
normalizePolarisViewer,
|
|
247
|
+
normalizeInboxUser,
|
|
248
|
+
normalize,
|
|
249
|
+
profilePictureAtSize
|
|
250
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* insta-chat - Unofficial Instagram Chat API
|
|
3
|
+
* Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
|
|
4
|
+
* Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
|
|
5
|
+
* Original author credit must be retained in all copies and derivatives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Turns a realtime MQTT message-sync patch into the same raw inbox event the
|
|
9
|
+
// poller produces, so listen() can hand either source to the same callback and
|
|
10
|
+
// the EventNormalizer never needs to know which one delivered it.
|
|
11
|
+
//
|
|
12
|
+
// The broker pushes a patch per Direct change:
|
|
13
|
+
// { op: "add", path: "/direct_v2/threads/<tid>/items/<iid>",
|
|
14
|
+
// value: "<json item>" }
|
|
15
|
+
// Only a new item under /items/ is an inbound message; activity indicators,
|
|
16
|
+
// seen state and reactions share the /direct_v2/threads/ prefix and are ignored.
|
|
17
|
+
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const { attachmentsFromContent } = require("../messageContent");
|
|
21
|
+
|
|
22
|
+
const ITEM_PATH_RE = /^\/direct_v2\/threads\/([^/]+)\/items\/([^/]+)$/;
|
|
23
|
+
|
|
24
|
+
function isMessagePatch(path, op) {
|
|
25
|
+
if (!ITEM_PATH_RE.test(String(path || ""))) return false;
|
|
26
|
+
// op is "add" for a new item; older captures omit it, so an absent op is fine.
|
|
27
|
+
return !op || String(op).toLowerCase() === "add";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Builds a raw event from a message-sync patch, or null when the patch is not an
|
|
31
|
+
// actionable inbound message (our own send, a reaction, a non-item change).
|
|
32
|
+
function rawEventFromMessageSync(event, selfId) {
|
|
33
|
+
const message = event && event.message ? event.message : event;
|
|
34
|
+
if (!message || typeof message !== "object") return null;
|
|
35
|
+
|
|
36
|
+
const match = String(message.path || "").match(ITEM_PATH_RE);
|
|
37
|
+
if (!match || !isMessagePatch(message.path, message.op)) return null;
|
|
38
|
+
|
|
39
|
+
const threadID = String(match[1]);
|
|
40
|
+
const messageID = String(message.item_id || match[2]);
|
|
41
|
+
|
|
42
|
+
const senderID = message.user_id != null ? String(message.user_id) : null;
|
|
43
|
+
// The broker echoes our own sends back over message-sync; drop them.
|
|
44
|
+
if (senderID && selfId && senderID === String(selfId)) return null;
|
|
45
|
+
|
|
46
|
+
const itemType = String(message.item_type || message.content_type || "text").toLowerCase();
|
|
47
|
+
if (/reaction|action_log|like|video_call/.test(itemType)) return null;
|
|
48
|
+
|
|
49
|
+
const body = message.text || message.text_body || "";
|
|
50
|
+
let attachments = [];
|
|
51
|
+
if (itemType !== "text") {
|
|
52
|
+
attachments = attachmentsFromContent(message.media || message.content || message);
|
|
53
|
+
}
|
|
54
|
+
if (itemType !== "text" && attachments.length === 0) {
|
|
55
|
+
// The item carried no parseable media URL; let the normalizer/bot fetch the
|
|
56
|
+
// thread history instead of emitting a message with nothing actionable.
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
type: "message",
|
|
62
|
+
threadID,
|
|
63
|
+
senderID,
|
|
64
|
+
messageID,
|
|
65
|
+
body,
|
|
66
|
+
attachments,
|
|
67
|
+
itemType,
|
|
68
|
+
repliedToItemId: message.replied_to_item_id || null,
|
|
69
|
+
repliedToMessage: null,
|
|
70
|
+
clientContext: message.client_context || null,
|
|
71
|
+
offlineThreadingId: message.offline_threading_id != null ? String(message.offline_threading_id) : null,
|
|
72
|
+
timestamp: Number(message.timestamp || message.timestamp_ms) || Date.now()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { rawEventFromMessageSync, isMessagePatch, ITEM_PATH_RE };
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* insta-chat - Unofficial Instagram Chat API
|
|
3
|
+
* Copyright (c) 2026 EryXenX (https://github.com/EryXenX)
|
|
4
|
+
* Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.
|
|
5
|
+
* Original author credit must be retained in all copies and derivatives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Instagram's realtime broker (edge-mqtt.facebook.com) speaks MQTToT with
|
|
9
|
+
// cookie_auth: the CONNECT password is "sessionid=<sessionid>" and the session
|
|
10
|
+
// cookie authenticates the connection, so no app login is needed. This client
|
|
11
|
+
// keeps that connection open and turns the broker's Direct message-sync and
|
|
12
|
+
// realtime-sub publishes into plain events.
|
|
13
|
+
//
|
|
14
|
+
// Message-sync (topic 146) is the Direct stream: the broker pushes Relay-style
|
|
15
|
+
// patches ({ path: "/direct_v2/threads/<threadID>", op, value }) as messages
|
|
16
|
+
// arrive, which is what makes listen() push instead of poll. Topics:
|
|
17
|
+
// 88 pubsub · 133 send-response · 135 iris-sub-response
|
|
18
|
+
// 146 message-sync · 149 realtime-sub · 150 region-hint
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const { EventEmitter } = require("events");
|
|
23
|
+
const crypto = require("crypto");
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
MQTToTConnection,
|
|
27
|
+
SocketMQTToTTransport,
|
|
28
|
+
compressPayload,
|
|
29
|
+
writeConnectPacket,
|
|
30
|
+
writeDisconnectPacket,
|
|
31
|
+
writePingreqPacket,
|
|
32
|
+
writePublishPacket,
|
|
33
|
+
writeSubscribePacket,
|
|
34
|
+
decodePacket,
|
|
35
|
+
tryDecompressPayload,
|
|
36
|
+
parseJsonPayload
|
|
37
|
+
} = require("./mqttot");
|
|
38
|
+
|
|
39
|
+
const REALTIME_HOST = "edge-mqtt.facebook.com";
|
|
40
|
+
const IG_REALTIME_APP_ID = 567067343352427;
|
|
41
|
+
const REALTIME_SUBSCRIBE_TOPICS = [88, 135, 149, 150, 133, 146];
|
|
42
|
+
|
|
43
|
+
// An Android app identity. The broker expects the app's own UA + app version in
|
|
44
|
+
// the CONNECT's appSpecificInfo; a web UA is rejected.
|
|
45
|
+
const ANDROID_UA =
|
|
46
|
+
"Instagram 449.0.0.52.84 Android (34/14; 480dpi; 1344x2992; Google/google; Pixel 8 Pro; husky; husky; en_US; 1079242191)";
|
|
47
|
+
const APP_VERSION = "449.0.0.52.84";
|
|
48
|
+
const CAPABILITIES = "3brTv10=";
|
|
49
|
+
const LOCALE = "en_US";
|
|
50
|
+
|
|
51
|
+
const TOPICS = {
|
|
52
|
+
PUBSUB: "88",
|
|
53
|
+
SEND_MESSAGE: "132",
|
|
54
|
+
SEND_MESSAGE_RESPONSE: "133",
|
|
55
|
+
IRIS_SUB: "134",
|
|
56
|
+
IRIS_SUB_RESPONSE: "135",
|
|
57
|
+
MESSAGE_SYNC: "146",
|
|
58
|
+
REALTIME_SUB: "149",
|
|
59
|
+
REGION_HINT: "150"
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
class RealtimeClient extends EventEmitter {
|
|
63
|
+
constructor(context, options = {}) {
|
|
64
|
+
super();
|
|
65
|
+
this.context = context || {};
|
|
66
|
+
this.options = options;
|
|
67
|
+
this.host = options.host || REALTIME_HOST;
|
|
68
|
+
this.transport = options.transport || new SocketMQTToTTransport(this.host, 443, { timeout: options.socketTimeout || 30000 });
|
|
69
|
+
this.connected = false;
|
|
70
|
+
this._packetId = 0;
|
|
71
|
+
this._loop = null;
|
|
72
|
+
this._pingTimer = null;
|
|
73
|
+
this._stopRequested = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_deviceId() {
|
|
77
|
+
const id = this.context.deviceId || this.context.phoneId || this.context.uuid || "";
|
|
78
|
+
if (!id) throw new Error("realtime: deviceId is required");
|
|
79
|
+
return String(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_sessionId() {
|
|
83
|
+
const raw = this.context.sessionid;
|
|
84
|
+
if (!raw) throw new Error("realtime: sessionid is required");
|
|
85
|
+
// The browser stores the sessionid URL-encoded (32715034217%3A...); the
|
|
86
|
+
// broker wants the decoded value.
|
|
87
|
+
try {
|
|
88
|
+
return decodeURIComponent(String(raw));
|
|
89
|
+
} catch (_) {
|
|
90
|
+
return String(raw);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
buildConnection() {
|
|
95
|
+
const deviceId = this._deviceId();
|
|
96
|
+
const userAgent = this.context.userAgent || ANDROID_UA;
|
|
97
|
+
const appVersion = this.context.appVersion || APP_VERSION;
|
|
98
|
+
return new MQTToTConnection({
|
|
99
|
+
clientIdentifier: deviceId.slice(0, 20),
|
|
100
|
+
clientInfo: {
|
|
101
|
+
userId: this.context.userId || 0,
|
|
102
|
+
userAgent,
|
|
103
|
+
clientCapabilities: 183,
|
|
104
|
+
endpointCapabilities: 0,
|
|
105
|
+
publishFormat: 1,
|
|
106
|
+
noAutomaticForeground: false,
|
|
107
|
+
makeUserAvailableInForeground: true,
|
|
108
|
+
deviceId,
|
|
109
|
+
isInitiallyForeground: true,
|
|
110
|
+
networkType: 1,
|
|
111
|
+
networkSubtype: 0,
|
|
112
|
+
clientMqttSessionId: Date.now() & 0xffffffff,
|
|
113
|
+
subscribeTopics: REALTIME_SUBSCRIBE_TOPICS,
|
|
114
|
+
clientType: "cookie_auth",
|
|
115
|
+
appId: IG_REALTIME_APP_ID,
|
|
116
|
+
deviceSecret: "",
|
|
117
|
+
clientStack: 3
|
|
118
|
+
},
|
|
119
|
+
password: `sessionid=${this._sessionId()}`,
|
|
120
|
+
appSpecificInfo: {
|
|
121
|
+
app_version: appVersion,
|
|
122
|
+
"X-IG-Capabilities": CAPABILITIES,
|
|
123
|
+
everclear_subscriptions: JSON.stringify({
|
|
124
|
+
inapp_notification_subscribe_comment: "17899377895239777",
|
|
125
|
+
inapp_notification_subscribe_comment_mention_and_reply: "17899377895239777",
|
|
126
|
+
video_call_participant_state_delivery: "17977239895057311",
|
|
127
|
+
presence_subscribe: "17846944882223835"
|
|
128
|
+
}),
|
|
129
|
+
"User-Agent": userAgent,
|
|
130
|
+
"Accept-Language": LOCALE.replace("_", "-"),
|
|
131
|
+
platform: "android",
|
|
132
|
+
ig_mqtt_route: "django",
|
|
133
|
+
pubsub_msg_type_blacklist: "direct, typing_type",
|
|
134
|
+
auth_cache_enabled: "0"
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async connect() {
|
|
140
|
+
if (this.connected) return this;
|
|
141
|
+
await this.transport.connect();
|
|
142
|
+
await this.transport.send(writeConnectPacket(this.buildConnection()));
|
|
143
|
+
const packet = decodePacket(await this.transport.recvPacket());
|
|
144
|
+
if (packet.packetType !== "connack" || packet.returnCode !== 0) {
|
|
145
|
+
throw new Error(`Realtime MQTT connect failed (return code ${packet.returnCode})`);
|
|
146
|
+
}
|
|
147
|
+
this.connected = true;
|
|
148
|
+
this._stopRequested = false;
|
|
149
|
+
this.emit("connected", { host: this.host });
|
|
150
|
+
this._startPing();
|
|
151
|
+
this._startLoop();
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_startPing() {
|
|
156
|
+
const keepAliveMs = this.options.keepAliveMs || 30000;
|
|
157
|
+
this._pingTimer = setInterval(() => {
|
|
158
|
+
if (this.connected) this.transport.send(writePingreqPacket()).catch(() => {});
|
|
159
|
+
}, keepAliveMs);
|
|
160
|
+
if (this._pingTimer.unref) this._pingTimer.unref();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_startLoop() {
|
|
164
|
+
this._loop = (async () => {
|
|
165
|
+
while (this.connected) {
|
|
166
|
+
let packet;
|
|
167
|
+
try {
|
|
168
|
+
packet = decodePacket(await this.transport.recvPacket());
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (!this._stopRequested) this.emit("error", err);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
this._handlePacket(packet);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.emit("error", err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.connected = false;
|
|
180
|
+
this.emit("disconnected", {});
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_handlePacket(packet) {
|
|
185
|
+
if (packet.packetType !== "publish" || packet.topic == null) return;
|
|
186
|
+
this.dispatch(packet.topic, packet.payload);
|
|
187
|
+
if (packet.qos === 1 && packet.packetId != null) {
|
|
188
|
+
this.transport.send(Buffer.concat([Buffer.from([0x40, 0x02]), uint16(packet.packetId)])).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
dispatch(topic, payload) {
|
|
193
|
+
const body = tryDecompressPayload(payload);
|
|
194
|
+
let parsed;
|
|
195
|
+
if (
|
|
196
|
+
topic === TOPICS.SEND_MESSAGE_RESPONSE ||
|
|
197
|
+
topic === TOPICS.IRIS_SUB_RESPONSE ||
|
|
198
|
+
topic === TOPICS.REALTIME_SUB ||
|
|
199
|
+
topic === TOPICS.MESSAGE_SYNC
|
|
200
|
+
) {
|
|
201
|
+
parsed = safeJson(body);
|
|
202
|
+
} else {
|
|
203
|
+
parsed = safeJson(payload);
|
|
204
|
+
}
|
|
205
|
+
this.emit("receive", { topic, payload: parsed });
|
|
206
|
+
if (topic === TOPICS.SEND_MESSAGE_RESPONSE) this.emit("send_response", parsed);
|
|
207
|
+
if (topic === TOPICS.IRIS_SUB_RESPONSE) this.emit("iris_sub_response", parsed);
|
|
208
|
+
if (topic === TOPICS.MESSAGE_SYNC) this._dispatchMessageSync(parsed);
|
|
209
|
+
if (topic === TOPICS.REALTIME_SUB) this._dispatchRealtimeSub(parsed);
|
|
210
|
+
return parsed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// message-sync payloads are a list of { data: [ { path, op, value } ] } patches.
|
|
214
|
+
_dispatchMessageSync(payload) {
|
|
215
|
+
if (!Array.isArray(payload)) return this.emit("iris", payload);
|
|
216
|
+
for (const item of payload) {
|
|
217
|
+
if (!item || typeof item !== "object" || !Array.isArray(item.data)) {
|
|
218
|
+
this.emit("iris", item);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const meta = {};
|
|
222
|
+
for (const [k, v] of Object.entries(item)) if (k !== "data") meta[k] = v;
|
|
223
|
+
for (const patch of item.data) {
|
|
224
|
+
if (!patch || typeof patch !== "object") continue;
|
|
225
|
+
const path = patch.path;
|
|
226
|
+
if (!path || patch.value == null) {
|
|
227
|
+
this.emit("iris", { ...meta, ...patch });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const value = parseMaybeJson(patch.value);
|
|
231
|
+
const threadID = threadIdFromPath(path);
|
|
232
|
+
const wrapped = {
|
|
233
|
+
...meta,
|
|
234
|
+
message: {
|
|
235
|
+
path,
|
|
236
|
+
op: patch.op,
|
|
237
|
+
thread_id: threadID,
|
|
238
|
+
...(value && typeof value === "object" ? value : { value })
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
this.emit(path.startsWith("/direct_v2/threads/") ? "message" : "thread_update", wrapped);
|
|
242
|
+
this._dispatchDirectEvent({ path, op: patch.op, value, threadID });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_dispatchRealtimeSub(payload) {
|
|
248
|
+
this.emit("realtime_sub", payload);
|
|
249
|
+
if (!payload || typeof payload !== "object") return;
|
|
250
|
+
let message = payload.message;
|
|
251
|
+
if (typeof message === "string") message = safeJson(Buffer.from(message));
|
|
252
|
+
if (!message || typeof message !== "object") return;
|
|
253
|
+
if (message.topic !== "direct") return;
|
|
254
|
+
let direct = message.json || message.payload;
|
|
255
|
+
if (typeof direct === "string") direct = safeJson(Buffer.from(direct));
|
|
256
|
+
this._dispatchDirectPayload(direct);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_dispatchDirectPayload(payload) {
|
|
260
|
+
if (!payload || typeof payload !== "object") return this._dispatchDirectEvent({ value: payload });
|
|
261
|
+
if (!Array.isArray(payload.data)) return this._dispatchDirectEvent(payload);
|
|
262
|
+
const meta = {};
|
|
263
|
+
for (const [k, v] of Object.entries(payload)) if (k !== "data") meta[k] = v;
|
|
264
|
+
for (const item of payload.data) {
|
|
265
|
+
this._dispatchDirectEvent(item && typeof item === "object" ? { ...meta, ...item } : { ...meta, value: item });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_dispatchDirectEvent(event) {
|
|
270
|
+
const out = { ...event };
|
|
271
|
+
let value = out.value;
|
|
272
|
+
if (typeof value === "string") value = safeJson(Buffer.from(value));
|
|
273
|
+
out.value = value;
|
|
274
|
+
if (out.path && !out.threadID) out.threadID = threadIdFromPath(out.path);
|
|
275
|
+
this.emit("direct", out);
|
|
276
|
+
const kind = classifyDirectEvent(out);
|
|
277
|
+
if (kind) this.emit(kind, out);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
publishBytes(topic, payload) {
|
|
281
|
+
this._packetId += 1;
|
|
282
|
+
const packet = writePublishPacket(topic, payload, 1, this._packetId);
|
|
283
|
+
return this.transport.send(packet);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
publishJson(topic, data) {
|
|
287
|
+
return this.publishBytes(topic, compressPayload(Buffer.from(JSON.stringify(data))));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
irisSubscribe(seqId, snapshotAtMs, appVersion) {
|
|
291
|
+
return this.publishJson(TOPICS.IRIS_SUB, {
|
|
292
|
+
seq_id: seqId,
|
|
293
|
+
snapshot_at_ms: snapshotAtMs,
|
|
294
|
+
snapshot_app_version: appVersion || this.context.appVersion || APP_VERSION
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
graphQlSubscribe(subscriptions) {
|
|
299
|
+
return this.publishJson(TOPICS.REALTIME_SUB, { sub: [].concat(subscriptions) });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
skywalkerSubscribe(subscriptions) {
|
|
303
|
+
return this.publishJson(TOPICS.PUBSUB, { sub: [].concat(subscriptions) });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async ping() {
|
|
307
|
+
if (!this.connected) throw new Error("Realtime client is not connected");
|
|
308
|
+
await this.transport.send(writePingreqPacket());
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
disconnect() {
|
|
313
|
+
this._stopRequested = true;
|
|
314
|
+
this.connected = false;
|
|
315
|
+
if (this._pingTimer) clearInterval(this._pingTimer);
|
|
316
|
+
this._pingTimer = null;
|
|
317
|
+
try { this.transport.send(writeDisconnectPacket()); } catch (_) {}
|
|
318
|
+
this.transport.disconnect();
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Extracts the thread id from a message-sync path such as
|
|
324
|
+
// "/direct_v2/threads/1234/items/..." or "/direct_v2/inbox/threads/1234".
|
|
325
|
+
function threadIdFromPath(path) {
|
|
326
|
+
const p = String(path || "");
|
|
327
|
+
let m = p.match(/^\/direct_v2\/threads\/([^/]+)/);
|
|
328
|
+
if (m) return m[1];
|
|
329
|
+
m = p.match(/^\/direct_v2\/inbox\/threads\/([^/]+)/);
|
|
330
|
+
if (m) return m[1];
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function classifyDirectEvent(event) {
|
|
335
|
+
const path = String(event.path || "").toLowerCase();
|
|
336
|
+
const action = String(event.action || "").toLowerCase();
|
|
337
|
+
const value = event.value;
|
|
338
|
+
const valueText = value && typeof value === "object" ? JSON.stringify(value).toLowerCase() : String(value || "").toLowerCase();
|
|
339
|
+
if (valueText.includes("activity_status") || path.includes("activity_indicator") || path.includes("typing")) return "typing";
|
|
340
|
+
if (path.includes("presence") || valueText.includes("is_active") || valueText.includes("last_active")) return "presence";
|
|
341
|
+
if (action === "mark_seen" || path.includes("/seen") || valueText.includes("seen")) return "seen";
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseMaybeJson(value) {
|
|
346
|
+
if (typeof value !== "string") return value;
|
|
347
|
+
const parsed = safeJson(Buffer.from(value));
|
|
348
|
+
return parsed === undefined ? value : parsed;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function safeJson(buffer) {
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
354
|
+
} catch (_) {
|
|
355
|
+
return buffer;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function uint16(value) {
|
|
360
|
+
const buf = Buffer.alloc(2);
|
|
361
|
+
buf.writeUInt16BE(value & 0xffff, 0);
|
|
362
|
+
return buf;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
RealtimeClient,
|
|
367
|
+
REALTIME_HOST,
|
|
368
|
+
IG_REALTIME_APP_ID,
|
|
369
|
+
REALTIME_SUBSCRIBE_TOPICS,
|
|
370
|
+
TOPICS,
|
|
371
|
+
ANDROID_UA,
|
|
372
|
+
APP_VERSION,
|
|
373
|
+
threadIdFromPath,
|
|
374
|
+
classifyDirectEvent,
|
|
375
|
+
newClientContext: () => crypto.randomUUID()
|
|
376
|
+
};
|