@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,31 @@
|
|
|
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
|
+
// fca.getUserID(idOrUsername, callback) -> the numeric user id (pk) as a string.
|
|
9
|
+
//
|
|
10
|
+
// Resolves an @handle to its id, or passes a numeric id through. Answers from
|
|
11
|
+
// the inbox-learned index first (free), then the logout-safe profile resolver.
|
|
12
|
+
// Returns null when the handle cannot be resolved.
|
|
13
|
+
module.exports = function (client) {
|
|
14
|
+
return async function getUserID(idOrUsername, callback) {
|
|
15
|
+
const cb = typeof callback === "function" ? callback : null;
|
|
16
|
+
const raw = String(idOrUsername == null ? "" : idOrUsername).trim().replace(/^@/, "");
|
|
17
|
+
if (/^\d+$/.test(raw)) {
|
|
18
|
+
if (cb) cb(null, raw);
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const profile = await client.getProfile(raw).catch(() => null);
|
|
23
|
+
const id = profile?.id || null;
|
|
24
|
+
if (cb) cb(null, id);
|
|
25
|
+
return id;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (cb) cb(error, null);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
// fca.getUserInfoV2(id | id[], callback) -> { <id>: <full profile> }.
|
|
9
|
+
//
|
|
10
|
+
// V1 getUserInfo returns the fca-shaped subset (name/firstName/username/
|
|
11
|
+
// profileUrl/thumbSrc/gender). V2 returns the *complete* profile Instagram
|
|
12
|
+
// exposes: id, username, full name, biography, follower/following/media counts,
|
|
13
|
+
// verified/private/business flags, the profile picture (square + HD), external
|
|
14
|
+
// url, category and business contact details.
|
|
15
|
+
//
|
|
16
|
+
// It is logout-safe: every source it reads (the users already learned from the
|
|
17
|
+
// inbox/thread views, web_profile_info, the account's own page) is failure
|
|
18
|
+
// tolerant, so a rate-limited (429) or login-redirected (302) profile endpoint
|
|
19
|
+
// can never be mistaken for an expired session.
|
|
20
|
+
module.exports = function (client) {
|
|
21
|
+
return async function getUserInfoV2(ids, callback) {
|
|
22
|
+
const cb = typeof callback === "function" ? callback : null;
|
|
23
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
24
|
+
try {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const id of list) {
|
|
27
|
+
const key = String(id);
|
|
28
|
+
const profile = await client.getProfile(key.replace(/^@/, "")).catch(() => null);
|
|
29
|
+
out[key] = profile
|
|
30
|
+
? {
|
|
31
|
+
id: profile.id,
|
|
32
|
+
userID: profile.id,
|
|
33
|
+
username: profile.username,
|
|
34
|
+
name: profile.fullName,
|
|
35
|
+
firstName: (profile.fullName || profile.username || "").split(" ")[0] || null,
|
|
36
|
+
biography: profile.biography,
|
|
37
|
+
profileUrl: profile.profilePicUrl,
|
|
38
|
+
thumbSrc: profile.profilePicUrl,
|
|
39
|
+
profilePicUrl: profile.profilePicUrl,
|
|
40
|
+
profilePicUrlHd: profile.profilePicUrlHd,
|
|
41
|
+
followerCount: profile.followerCount,
|
|
42
|
+
followingCount: profile.followingCount,
|
|
43
|
+
mediaCount: profile.mediaCount,
|
|
44
|
+
isVerified: profile.isVerified,
|
|
45
|
+
isPrivate: profile.isPrivate,
|
|
46
|
+
isBusinessAccount: profile.isBusinessAccount,
|
|
47
|
+
isProfessionalAccount: profile.isProfessionalAccount,
|
|
48
|
+
externalUrl: profile.externalUrl,
|
|
49
|
+
category: profile.category,
|
|
50
|
+
fbid: profile.fbid,
|
|
51
|
+
gender: null
|
|
52
|
+
}
|
|
53
|
+
: null;
|
|
54
|
+
}
|
|
55
|
+
if (cb) cb(null, out);
|
|
56
|
+
return out;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (cb) cb(error, null);
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
// fca.getUserProfile(idOrUsername, callback) -> the full normalized profile
|
|
9
|
+
// object (see src/profileParse.js): id, username, fullName, biography, counts,
|
|
10
|
+
// flags, pictures, externalUrl, category and contact details.
|
|
11
|
+
//
|
|
12
|
+
// Thin FCA-shaped wrapper over the native getUserProfile(); returns null (never
|
|
13
|
+
// throws) when nothing resolves, so a caller can fall back safely.
|
|
14
|
+
module.exports = function (client) {
|
|
15
|
+
return async function getUserProfile(idOrUsername, callback) {
|
|
16
|
+
const cb = typeof callback === "function" ? callback : null;
|
|
17
|
+
try {
|
|
18
|
+
const profile = await client.getProfile(String(idOrUsername).replace(/^@/, ""));
|
|
19
|
+
if (cb) cb(null, profile);
|
|
20
|
+
return profile;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (cb) cb(error, null);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
};
|
package/src/api.js
CHANGED
|
@@ -47,11 +47,46 @@ class InstaAPI {
|
|
|
47
47
|
this.reaction = new ReactionMethods(this.http);
|
|
48
48
|
this.typing = new TypingMethods(this.http);
|
|
49
49
|
this.thread = new ThreadMethods(this.http);
|
|
50
|
-
this.user = new UserMethods(this.http);
|
|
50
|
+
this.user = new UserMethods(this.http, () => ({ id: this.userID, username: this.username }), (key) => this._learnedUser(key));
|
|
51
51
|
this.inbox = new InboxMethods(this.http, () => this.userID, {
|
|
52
|
-
minPollIntervalMs: options.minPollIntervalMs
|
|
52
|
+
minPollIntervalMs: options.minPollIntervalMs,
|
|
53
|
+
identity: this.identity,
|
|
54
|
+
realtime: options.realtime
|
|
53
55
|
});
|
|
54
56
|
this.media = new MediaMethods(this.http, this.identity);
|
|
57
|
+
|
|
58
|
+
// pk / @handle -> user record, learned from inbox/thread introspection. The
|
|
59
|
+
// profile REST paths rate-limit (429) or 302 to login for a web session, so
|
|
60
|
+
// a lookup for a user the bot has already seen answers from here for free.
|
|
61
|
+
this._userIndex = new Map();
|
|
62
|
+
this._learnInboxUsers();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_learnInboxUsers() {
|
|
66
|
+
const original = this.inbox.getInbox.bind(this.inbox);
|
|
67
|
+
this.inbox.getInbox = async (...args) => {
|
|
68
|
+
const result = await original(...args);
|
|
69
|
+
for (const t of result?.threads || []) {
|
|
70
|
+
for (const u of t.users || []) this._rememberUser(u);
|
|
71
|
+
if (t.viewer) this._rememberUser(t.viewer);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_rememberUser(u) {
|
|
78
|
+
if (!u || typeof u !== "object") return;
|
|
79
|
+
const keys = [u.id, u.pk, u.interop_messaging_user_fbid, u.username];
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
if (key) this._userIndex.set(String(key).toLowerCase(), u);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The user record for a pk, interop fbid or @handle, from data already seen.
|
|
86
|
+
// Returns null when the user has not been observed yet.
|
|
87
|
+
_learnedUser(key) {
|
|
88
|
+
if (key == null) return null;
|
|
89
|
+
return this._userIndex.get(String(key).replace(/^@/, "").toLowerCase()) || null;
|
|
55
90
|
}
|
|
56
91
|
|
|
57
92
|
_loadSessionParams(file) {
|
|
@@ -122,6 +157,17 @@ class InstaAPI {
|
|
|
122
157
|
} catch (_) {
|
|
123
158
|
// GraphQL tokens are only needed for sending; polling still works without them.
|
|
124
159
|
}
|
|
160
|
+
// EP.CURRENT_USER 302s to the login page for a web session, so the handle is
|
|
161
|
+
// usually still unknown here. The home shell Instagram server-renders embeds
|
|
162
|
+
// the viewer's own profile, which fills it in without another rate-limited call.
|
|
163
|
+
if (!this.username && this.userID) {
|
|
164
|
+
try {
|
|
165
|
+
const selfProfile = await this.user.getProfile(this.userID);
|
|
166
|
+
if (selfProfile?.username) this.username = selfProfile.username;
|
|
167
|
+
} catch (_) {
|
|
168
|
+
// A missing handle is cosmetic; the id from the cookie is enough.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
125
171
|
return { userID: this.userID, username: this.username };
|
|
126
172
|
}
|
|
127
173
|
|
|
@@ -209,6 +255,21 @@ class InstaAPI {
|
|
|
209
255
|
return this.user.search(query, limit);
|
|
210
256
|
}
|
|
211
257
|
|
|
258
|
+
// Full profile (id or @handle) merged from every source a web session can
|
|
259
|
+
// read. Logout-safe: never throws, returns null when nothing resolves.
|
|
260
|
+
getUserProfile(idOrUsername, opts) {
|
|
261
|
+
return this.user.getProfile(idOrUsername, opts);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The best available profile-picture URL. `opts.size` resizes via the CDN.
|
|
265
|
+
async getProfilePicture(idOrUsername, opts = {}) {
|
|
266
|
+
const profile = await this.user.getProfile(idOrUsername);
|
|
267
|
+
const url = profile?.profilePicUrlHd || profile?.profilePicUrl || null;
|
|
268
|
+
if (!url) return null;
|
|
269
|
+
const { profilePictureAtSize } = require("./profileParse");
|
|
270
|
+
return opts && opts.size ? profilePictureAtSize(url, opts.size) : url;
|
|
271
|
+
}
|
|
272
|
+
|
|
212
273
|
sendPhoto(threadID, filePath, options) {
|
|
213
274
|
return this.media.sendPhoto(threadID, filePath, options);
|
|
214
275
|
}
|
|
@@ -256,6 +317,59 @@ class InstaAPI {
|
|
|
256
317
|
stopListening() {
|
|
257
318
|
this.inbox.stopListening();
|
|
258
319
|
}
|
|
320
|
+
|
|
321
|
+
// ------------------------------------------------------------- realtime
|
|
322
|
+
// A standalone MQTT (realtime) connection, for callers that want raw push
|
|
323
|
+
// events (typing/seen/presence) or to drive their own dispatch, rather than
|
|
324
|
+
// the inbox callback listen() wires up. listen({ realtime: true }) uses the
|
|
325
|
+
// same RealtimeClient under the hood.
|
|
326
|
+
_realtimeContext() {
|
|
327
|
+
return {
|
|
328
|
+
sessionid: this.http.getCookie("sessionid"),
|
|
329
|
+
userId: this.userID,
|
|
330
|
+
deviceId: this.identity.deviceId,
|
|
331
|
+
uuid: this.identity.uuid
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
realtimeClient(options = {}) {
|
|
336
|
+
const { RealtimeClient } = require("./realtime/client");
|
|
337
|
+
return new RealtimeClient(this._realtimeContext(), options);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async realtimeConnect(options = {}) {
|
|
341
|
+
if (!this._realtime) this._realtime = this.realtimeClient(options);
|
|
342
|
+
await this._realtime.connect();
|
|
343
|
+
return this._realtime;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
realtimeOn(event, handler) {
|
|
347
|
+
if (!this._realtime) this._realtime = this.realtimeClient();
|
|
348
|
+
this._realtime.on(event, handler);
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Subscribes the connected realtime client to the Direct message-sync stream,
|
|
353
|
+
// reading the cursor (seq_id/snapshot_at_ms) from the inbox query.
|
|
354
|
+
async realtimeSubscribe() {
|
|
355
|
+
if (!this._realtime) throw new Error("realtimeSubscribe: call realtimeConnect() first");
|
|
356
|
+
const state = await this.inbox.fetchRealtimeState();
|
|
357
|
+
if (!state) throw new Error("realtimeSubscribe: Instagram did not return a realtime sync cursor");
|
|
358
|
+
await this._realtime.irisSubscribe(state.seqId, state.snapshotAtMs);
|
|
359
|
+
return state;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async realtimePing() {
|
|
363
|
+
if (!this._realtime) throw new Error("realtimePing: call realtimeConnect() first");
|
|
364
|
+
return this._realtime.ping();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
realtimeDisconnect() {
|
|
368
|
+
if (this._realtime) {
|
|
369
|
+
this._realtime.disconnect();
|
|
370
|
+
this._realtime = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
259
373
|
}
|
|
260
374
|
|
|
261
375
|
module.exports = InstaAPI;
|
package/src/app/InstaClient.js
CHANGED
|
@@ -319,6 +319,29 @@ class InstaClient {
|
|
|
319
319
|
return this._igPictureForUser(key, /^\d+$/.test(key) ? "id" : "username");
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// Full profile (id or @handle) from the native resolver. Merges the record
|
|
323
|
+
// learned from the inbox/thread views with web_profile_info and the account's
|
|
324
|
+
// own page, so it answers even when the profile REST paths rate-limit (429) or
|
|
325
|
+
// 302 to the login page. Never throws; returns null when nothing resolves.
|
|
326
|
+
async getProfile(idOrUsername, opts) {
|
|
327
|
+
if (typeof this.api.getUserProfile !== "function") return null;
|
|
328
|
+
const profile = await this.api.getUserProfile(idOrUsername, opts).catch(() => null);
|
|
329
|
+
if (profile) this._rememberProfile(profile);
|
|
330
|
+
return profile;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Seeds the FCA caches (picture + user index) from a normalized profile so
|
|
334
|
+
// later getUserInfo / graph-bridge lookups are a cache hit.
|
|
335
|
+
_rememberProfile(profile) {
|
|
336
|
+
if (!profile) return;
|
|
337
|
+
const url = profile.profilePicUrlHd || profile.profilePicUrl;
|
|
338
|
+
for (const key of [profile.id, profile.pk, profile.username, profile.fbid]) {
|
|
339
|
+
if (!key) continue;
|
|
340
|
+
if (url) this._picCache.set(String(key), url);
|
|
341
|
+
if (profile.username) this._picCache.set(`name:${String(key)}`, profile.username);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
322
345
|
// Frozen commands (pp.js) call the fca name `getPictureUrl(id, type)` and
|
|
323
346
|
// guard with `typeof api.getPictureUrl === "function"`; without it the call
|
|
324
347
|
// is skipped and the avatar is blank. `type` mirrors fca's optional
|
|
@@ -41,19 +41,31 @@ function createFcaClient(client) {
|
|
|
41
41
|
}),
|
|
42
42
|
users: compact({
|
|
43
43
|
getInfo: bindOptionalMethod(client, "getUserInfo"),
|
|
44
|
+
getInfoV2: bindOptionalMethod(client, "getUserInfoV2"),
|
|
45
|
+
getProfile: bindOptionalMethod(client, "getUserProfile"),
|
|
46
|
+
getProfilePicture: bindOptionalMethod(client, "getProfilePictureV2"),
|
|
47
|
+
getBio: bindOptionalMethod(client, "getProfileBio"),
|
|
48
|
+
getFriendship: bindOptionalMethod(client, "getUserFriendship"),
|
|
49
|
+
getID: bindOptionalMethod(client, "getUserID"),
|
|
44
50
|
getUID: bindOptionalMethod(client, "getUID"),
|
|
45
51
|
getByUsername: bindOptionalMethod(client, "getUserByUsername"),
|
|
46
52
|
getPictureUrl: bindOptionalMethod(client, "getProfilePictureUrl")
|
|
47
53
|
}),
|
|
48
54
|
account: compact({
|
|
49
55
|
getCurrentUserID: bindOptionalMethod(client, "getCurrentUserID"),
|
|
56
|
+
getSelfInfo: bindOptionalMethod(client, "getSelfInfo"),
|
|
50
57
|
getAppState: bindOptionalMethod(client, "getAppState"),
|
|
51
58
|
getCookies: bindOptionalMethod(client, "getCookies"),
|
|
52
59
|
setOptions: bindOptionalMethod(client, "setOptions")
|
|
53
60
|
}),
|
|
54
61
|
realtime: compact({
|
|
55
62
|
listen: bindOptionalMethod(client, "listen"),
|
|
56
|
-
stop: bindOptionalMethod(client, "stopListening")
|
|
63
|
+
stop: bindOptionalMethod(client, "stopListening"),
|
|
64
|
+
connect: bindOptionalMethod(client, "realtimeConnect"),
|
|
65
|
+
on: bindOptionalMethod(client, "realtimeOn"),
|
|
66
|
+
subscribe: bindOptionalMethod(client, "realtimeSubscribe"),
|
|
67
|
+
ping: bindOptionalMethod(client, "realtimePing"),
|
|
68
|
+
disconnect: bindOptionalMethod(client, "realtimeDisconnect")
|
|
57
69
|
}),
|
|
58
70
|
http: compact({
|
|
59
71
|
get: bindOptionalMethod(client, "httpGet"),
|
package/src/constants.js
CHANGED
|
@@ -26,7 +26,12 @@ const DOC_ID = {
|
|
|
26
26
|
MARK_THREAD_READ: "27399783383056109",
|
|
27
27
|
THREAD_DETAIL: "29619996517588618",
|
|
28
28
|
INBOX: "27909866362025854",
|
|
29
|
-
UNSEND_MESSAGE: "26948700068153789"
|
|
29
|
+
UNSEND_MESSAGE: "26948700068153789",
|
|
30
|
+
// Profile Relay operations. PolarisProfilePageContentQuery is the one the
|
|
31
|
+
// profile page itself issues; PolarisUserRootQuery is its fallback. Both take
|
|
32
|
+
// a `username` variable and return the full `data.user` profile.
|
|
33
|
+
PROFILE_PAGE_CONTENT: "7898261790222653",
|
|
34
|
+
PROFILE_USER_ROOT: "25317500992033585"
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
const EP = {
|
|
@@ -49,6 +54,10 @@ const EP = {
|
|
|
49
54
|
USER_INFO: (userID) => `${BASE}/users/${userID}/info/`,
|
|
50
55
|
USER_BY_USERNAME: (username) => `${BASE}/users/${username}/usernameinfo/`,
|
|
51
56
|
USER_SEARCH: (query, count) => `${BASE}/users/search/?q=${encodeURIComponent(query)}&count=${count}`,
|
|
57
|
+
// The canonical profile JSON a logged-in web session reads. Unlike the
|
|
58
|
+
// /users/<id>/info/ and usernameinfo REST paths, it is not 302'd to the login
|
|
59
|
+
// page, and it carries follower/media counts, biography and the account flags.
|
|
60
|
+
WEB_PROFILE_INFO: (username) => `${BASE}/users/web_profile_info/?username=${encodeURIComponent(username)}`,
|
|
52
61
|
TOPSEARCH: (query) => `${WEB}/web/search/topsearch/?query=${encodeURIComponent(query)}&context=blended`,
|
|
53
62
|
RUPLOAD_PHOTO: (uploadName) => `https://i.instagram.com/rupload_igphoto/${uploadName}`,
|
|
54
63
|
RUPLOAD_VIDEO: (uploadName) => `https://i.instagram.com/rupload_igvideo/${uploadName}`,
|
package/src/methods/inbox.js
CHANGED
|
@@ -27,6 +27,10 @@ class InboxMethods {
|
|
|
27
27
|
constructor(http, getUserID, options = {}) {
|
|
28
28
|
this.http = http;
|
|
29
29
|
this.getUserID = getUserID;
|
|
30
|
+
// The device identity (deviceId/uuid) the realtime MQTT connection needs.
|
|
31
|
+
this.identity = options.identity || {};
|
|
32
|
+
// { enabled, fallback } — realtime is opt-in via listen(); see listen().
|
|
33
|
+
this.realtimeOptions = options.realtime || null;
|
|
30
34
|
// Lowest poll interval listen() will honour, in ms. Instagram has no DM
|
|
31
35
|
// push, so reply latency is bounded by one poll cycle. The 15000 default is
|
|
32
36
|
// the conservative safe floor for a personal account; operators on a
|
|
@@ -327,7 +331,82 @@ class InboxMethods {
|
|
|
327
331
|
return out;
|
|
328
332
|
}
|
|
329
333
|
|
|
330
|
-
|
|
334
|
+
// Starts the realtime (MQTT) listener, falling back to the inbox poller when
|
|
335
|
+
// the broker is unreachable and options.realtime.fallback !== false. Realtime
|
|
336
|
+
// is push (sub-second); the poller is the always-works safety net.
|
|
337
|
+
listen(callback, intervalMs) {
|
|
338
|
+
const rtOpts = this.realtimeOptions;
|
|
339
|
+
if (rtOpts && rtOpts.enabled) {
|
|
340
|
+
const { RealtimeListener } = require("../realtime/listener");
|
|
341
|
+
const allowFallback = rtOpts.fallback !== false;
|
|
342
|
+
let stopped = false;
|
|
343
|
+
let stopPoll = null;
|
|
344
|
+
const listener = new RealtimeListener(
|
|
345
|
+
{
|
|
346
|
+
sessionid: this.http.getCookie("sessionid"),
|
|
347
|
+
userId: this.getUserID(),
|
|
348
|
+
deviceId: this.identity.deviceId,
|
|
349
|
+
uuid: this.identity.uuid,
|
|
350
|
+
userAgent: rtOpts.userAgent,
|
|
351
|
+
appVersion: rtOpts.appVersion
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
host: rtOpts.host,
|
|
355
|
+
transport: rtOpts.transport,
|
|
356
|
+
keepAliveMs: rtOpts.keepAliveMs,
|
|
357
|
+
seqId: rtOpts.seqId,
|
|
358
|
+
snapshotAtMs: rtOpts.snapshotAtMs,
|
|
359
|
+
getRealtimeState: rtOpts.getRealtimeState || (() => this.fetchRealtimeState()),
|
|
360
|
+
onReady: rtOpts.onReady,
|
|
361
|
+
onError: (err) => {
|
|
362
|
+
// Before the subscription is confirmed a failure means realtime is
|
|
363
|
+
// not usable; degrade to polling so the bot still gets events.
|
|
364
|
+
if (allowFallback && !listener._subscribed && !stopped) {
|
|
365
|
+
stopPoll = this._startPolling(callback, intervalMs);
|
|
366
|
+
} else {
|
|
367
|
+
callback(err, null);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
listener.start(callback).catch((err) => {
|
|
373
|
+
if (allowFallback && !stopped) stopPoll = this._startPolling(callback, intervalMs);
|
|
374
|
+
else callback(err, null);
|
|
375
|
+
});
|
|
376
|
+
const stop = () => {
|
|
377
|
+
stopped = true;
|
|
378
|
+
listener.stop();
|
|
379
|
+
if (stopPoll) stopPoll();
|
|
380
|
+
};
|
|
381
|
+
this._realtimeListener = listener;
|
|
382
|
+
this._stopListen = stop;
|
|
383
|
+
return stop;
|
|
384
|
+
}
|
|
385
|
+
this._stopListen = this._startPolling(callback, intervalMs);
|
|
386
|
+
return this._stopListen;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The iris (Direct message-sync) subscription cursor: seq_id + snapshot time.
|
|
390
|
+
// Read from the inbox query when it exposes them; otherwise the listener
|
|
391
|
+
// connects without a subscription (a live message still opens the stream).
|
|
392
|
+
async fetchRealtimeState() {
|
|
393
|
+
try {
|
|
394
|
+
const res = await this.http.postGraphQL(
|
|
395
|
+
"PolarisDirectInboxQuery",
|
|
396
|
+
DOC_ID.INBOX,
|
|
397
|
+
{ ...INBOX_VARIABLES, device_id_for_iris_subscription: this.http.session?.__hsi || this.http.uuid() },
|
|
398
|
+
"https://www.instagram.com/direct/inbox/"
|
|
399
|
+
);
|
|
400
|
+
const box = res?.data?.get_slide_mailbox_for_iris_subscription || {};
|
|
401
|
+
const seqId = box.iris_inactive_subscription_uq_seq_id;
|
|
402
|
+
if (seqId == null) return null;
|
|
403
|
+
return { seqId: Number(seqId), snapshotAtMs: Date.now() };
|
|
404
|
+
} catch (_) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_startPolling(callback, intervalMs = 20000) {
|
|
331
410
|
const MIN_INTERVAL_MS = this.minPollIntervalMs;
|
|
332
411
|
const BASE_INTERVAL_MS = Math.max(Number(intervalMs) || 20000, MIN_INTERVAL_MS);
|
|
333
412
|
const BACKOFF_START_MS = 30000;
|
|
@@ -482,6 +561,8 @@ class InboxMethods {
|
|
|
482
561
|
}
|
|
483
562
|
|
|
484
563
|
stopListening() {
|
|
564
|
+
if (typeof this._stopListen === "function") this._stopListen();
|
|
565
|
+
this._stopListen = null;
|
|
485
566
|
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
486
567
|
}
|
|
487
568
|
}
|
package/src/methods/user.js
CHANGED
|
@@ -5,16 +5,37 @@
|
|
|
5
5
|
* Original author credit must be retained in all copies and derivatives.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { EP } = require("../constants");
|
|
8
|
+
const { EP, WEB, DOC_ID } = require("../constants");
|
|
9
|
+
const { parseProfilePage, normalizeWebProfileInfo, normalizeGraphqlUser, normalizeInboxUser, normalize } = require("../profileParse");
|
|
10
|
+
|
|
11
|
+
// A small cache so a profile fetched once (from whichever source answered) is
|
|
12
|
+
// reused instead of re-hit, and a per-user failure is not retried in a tight
|
|
13
|
+
// loop. Instagram rate-limits profile reads aggressively for web sessions.
|
|
14
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
9
15
|
|
|
10
16
|
class UserMethods {
|
|
11
|
-
constructor(http) {
|
|
17
|
+
constructor(http, getSelf, getLearned) {
|
|
12
18
|
this.http = http;
|
|
19
|
+
// Resolves the logged-in account ({ id, username }) so a profile lookup by
|
|
20
|
+
// the bot's own id can find its handle without a rate-limited REST call.
|
|
21
|
+
this.getSelf = typeof getSelf === "function" ? getSelf : null;
|
|
22
|
+
// Returns a raw user record already seen in the inbox/thread views for a pk,
|
|
23
|
+
// interop fbid or @handle — the offline source that survives rate-limiting.
|
|
24
|
+
this.getLearned = typeof getLearned === "function" ? getLearned : null;
|
|
25
|
+
this._profileCache = new Map(); // key -> { at, value }
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
// The id-only lookup. /api/v1/users/<id>/info/ is a REST path Instagram 302s
|
|
29
|
+
// to the login page for a web session (the HTTP layer reads any 3xx as an
|
|
30
|
+
// expired session), so this is best-effort: callers that need a full profile
|
|
31
|
+
// should use getProfile().
|
|
15
32
|
async getInfo(userID) {
|
|
16
|
-
|
|
17
|
-
|
|
33
|
+
try {
|
|
34
|
+
const res = await this.http.get(EP.USER_INFO(userID));
|
|
35
|
+
return res?.user || null;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
18
39
|
}
|
|
19
40
|
|
|
20
41
|
async getInfoByUsername(username) {
|
|
@@ -59,6 +80,161 @@ class UserMethods {
|
|
|
59
80
|
return [];
|
|
60
81
|
}
|
|
61
82
|
}
|
|
83
|
+
|
|
84
|
+
// --------------------------------------------------------------- profiles
|
|
85
|
+
// Full profile for a user id or @handle. Tries every source a web session can
|
|
86
|
+
// actually read, in order, and merges whatever each returns so the result is
|
|
87
|
+
// as complete as Instagram allows:
|
|
88
|
+
//
|
|
89
|
+
// 1. the user record already learned from the inbox/thread views (free, and
|
|
90
|
+
// the only source that still answers once the profile REST paths 429)
|
|
91
|
+
// 2. web_profile_info REST (canonical: follower/media counts, verified, ...)
|
|
92
|
+
// 3. the logged-in account's own page HTML (bio, hd picture, account flags)
|
|
93
|
+
//
|
|
94
|
+
// Never throws: an id that cannot be resolved returns null, and a source that
|
|
95
|
+
// 302s/429s is simply skipped — a profile lookup must not surface as a dead
|
|
96
|
+
// session.
|
|
97
|
+
async getProfile(idOrUsername, opts = {}) {
|
|
98
|
+
const raw = String(idOrUsername == null ? "" : idOrUsername).trim().replace(/^@/, "");
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const byId = /^\d+$/.test(raw);
|
|
101
|
+
const key = (byId ? "id:" : "user:") + raw.toLowerCase();
|
|
102
|
+
|
|
103
|
+
const cached = this._profileCache.get(key);
|
|
104
|
+
if (!opts.refresh && cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
|
|
105
|
+
|
|
106
|
+
let username = byId ? null : raw;
|
|
107
|
+
const parts = [];
|
|
108
|
+
|
|
109
|
+
// 0) Whatever the inbox/thread views already told us about this user.
|
|
110
|
+
if (this.getLearned) {
|
|
111
|
+
const learned = this.getLearned(raw);
|
|
112
|
+
if (learned) {
|
|
113
|
+
const norm = normalizeInboxUser(learned);
|
|
114
|
+
if (norm) parts.push(norm);
|
|
115
|
+
if (!username && learned.username) username = learned.username;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 1) Canonical REST profile (username only). Also resolves an id -> username.
|
|
120
|
+
if (!username && byId) {
|
|
121
|
+
const self = this.getSelf && this.getSelf();
|
|
122
|
+
if (self && String(self.id) === raw && self.username) {
|
|
123
|
+
// The logged-in account answers offline (the cookie carries its handle).
|
|
124
|
+
username = self.username;
|
|
125
|
+
} else {
|
|
126
|
+
const u = await this.getInfo(raw).catch(() => null);
|
|
127
|
+
if (u?.username) username = u.username;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (username) {
|
|
131
|
+
const rest = await this._webProfileInfo(username);
|
|
132
|
+
if (rest) parts.push(rest);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 1b) Profile GraphQL (PolarisProfilePageContentQuery). Usually rate-limited
|
|
136
|
+
// (429) for a web session, but when it does answer it carries the richest
|
|
137
|
+
// data: biography, counts, external url and the account flags.
|
|
138
|
+
if (username) {
|
|
139
|
+
const gql = await this._profileFromGraphQL(username);
|
|
140
|
+
if (gql) parts.push(gql);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2) Profile pages Instagram server-renders: the account's own profile and
|
|
144
|
+
// the home/inbox shell both embed the *viewer's* full PolarisViewer blob, so
|
|
145
|
+
// the logged-in account is always resolvable even when REST 302s/429s.
|
|
146
|
+
const self = this.getSelf && this.getSelf();
|
|
147
|
+
const isSelf = !!(self && self.id && String(self.id) === raw);
|
|
148
|
+
if (username) {
|
|
149
|
+
const page = await this._profileFromPage(username);
|
|
150
|
+
if (page) parts.push(page);
|
|
151
|
+
}
|
|
152
|
+
if (isSelf && !parts.some((p) => p && p.username)) {
|
|
153
|
+
const shell = await this._viewerProfileFromShell();
|
|
154
|
+
if (shell) parts.push(shell);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!parts.length) return null;
|
|
158
|
+
|
|
159
|
+
const merged = this._mergeProfiles(parts, { id: byId ? raw : null, username });
|
|
160
|
+
const entry = { at: Date.now(), value: merged };
|
|
161
|
+
this._profileCache.set(key, entry);
|
|
162
|
+
if (merged.username) this._profileCache.set("user:" + merged.username.toLowerCase(), entry);
|
|
163
|
+
if (merged.id) this._profileCache.set("id:" + merged.id, entry);
|
|
164
|
+
return merged;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async _webProfileInfo(username) {
|
|
168
|
+
try {
|
|
169
|
+
const res = await this.http.get(EP.WEB_PROFILE_INFO(username));
|
|
170
|
+
const user = res?.data?.user || res?.user;
|
|
171
|
+
return normalizeWebProfileInfo(user);
|
|
172
|
+
} catch (_) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _profileFromPage(username) {
|
|
178
|
+
try {
|
|
179
|
+
const res = await this.http.client.get(`${WEB}/${encodeURIComponent(username)}/`, { maxRedirects: 5 });
|
|
180
|
+
return parseProfilePage(typeof res.data === "string" ? res.data : "", username);
|
|
181
|
+
} catch (_) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The viewer's own profile, read from the home shell Instagram server-renders
|
|
187
|
+
// for a logged-in session (it embeds the full PolarisViewer blob).
|
|
188
|
+
async _viewerProfileFromShell() {
|
|
189
|
+
try {
|
|
190
|
+
const res = await this.http.client.get(`${WEB}/`, { maxRedirects: 5 });
|
|
191
|
+
return parseProfilePage(typeof res.data === "string" ? res.data : "");
|
|
192
|
+
} catch (_) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Profile GraphQL. Best-effort: a 429 / "Rate limit exceeded" / rotated doc_id
|
|
198
|
+
// is swallowed so the caller simply falls through to the next source.
|
|
199
|
+
async _profileFromGraphQL(username) {
|
|
200
|
+
const referer = `${WEB}/${encodeURIComponent(username)}/`;
|
|
201
|
+
const docs = [
|
|
202
|
+
["PolarisProfilePageContentQuery", DOC_ID.PROFILE_PAGE_CONTENT],
|
|
203
|
+
["PolarisUserRootQuery", DOC_ID.PROFILE_USER_ROOT]
|
|
204
|
+
];
|
|
205
|
+
for (const [name, docId] of docs) {
|
|
206
|
+
try {
|
|
207
|
+
const res = await this.http.postGraphQL(name, docId, { username }, referer);
|
|
208
|
+
if (Array.isArray(res?.errors) && res.errors.length) continue;
|
|
209
|
+
const user = res?.data?.user;
|
|
210
|
+
const norm = normalizeGraphqlUser(user);
|
|
211
|
+
if (norm && norm.username) return norm;
|
|
212
|
+
} catch (_) {
|
|
213
|
+
// try the next operation
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Later sources fill only the fields earlier ones left null, so the canonical
|
|
220
|
+
// REST profile wins and the HTML/learned data tops it up.
|
|
221
|
+
_mergeProfiles(parts, fallback) {
|
|
222
|
+
const out = normalize({ id: fallback.id, username: fallback.username });
|
|
223
|
+
for (const part of parts) {
|
|
224
|
+
for (const [k, v] of Object.entries(part)) {
|
|
225
|
+
if (k === "raw") continue;
|
|
226
|
+
const empty = out[k] === null || out[k] === undefined || out[k] === "";
|
|
227
|
+
if (empty && v !== null && v !== undefined && v !== "") out[k] = v;
|
|
228
|
+
}
|
|
229
|
+
for (const flag of ["isVerified", "isPrivate", "isBusinessAccount", "isProfessionalAccount", "hasProfilePic"]) {
|
|
230
|
+
if (part[flag] === true) out[flag] = true;
|
|
231
|
+
}
|
|
232
|
+
if (!out.raw && part.raw) out.raw = part.raw;
|
|
233
|
+
}
|
|
234
|
+
if (!out.id && fallback.id) out.id = String(fallback.id);
|
|
235
|
+
if (!out.pk) out.pk = out.id;
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
62
238
|
}
|
|
63
239
|
|
|
64
240
|
module.exports = UserMethods;
|