@eryxenx/insta-chat 2.0.11 → 2.1.1
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/media.js +28 -14
- 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/media.js
CHANGED
|
@@ -78,20 +78,34 @@ class MediaMethods {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
async _commitMedia(threadID, attachmentFbid, options = {}) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
// Instagram intermittently answers IGDirectMediaSendMutation with a
|
|
82
|
+
// transient `field_exception` even though the upload already succeeded; a
|
|
83
|
+
// short backoff retry (same attachment fbid, fresh offline_threading_id)
|
|
84
|
+
// commits it. Without this a media send fails once and the caller sees a
|
|
85
|
+
// hard error while the bytes are already uploaded.
|
|
86
|
+
for (let attempt = 0; ; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
return await this.http.postGraphQL(
|
|
89
|
+
"IGDirectMediaSendMutation",
|
|
90
|
+
DOC_ID.MEDIA_SEND,
|
|
91
|
+
{
|
|
92
|
+
attachment_fbid: String(attachmentFbid),
|
|
93
|
+
thread_id: String(threadID),
|
|
94
|
+
offline_threading_id: this._offlineThreadingId(),
|
|
95
|
+
reply_to_message_id: options.replyToMessageId || null,
|
|
96
|
+
forwarded_from_thread_id: null,
|
|
97
|
+
is_forwarded_from_own_message: null
|
|
98
|
+
},
|
|
99
|
+
this._threadReferer(threadID),
|
|
100
|
+
{ throwOnError: true }
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const msg = String((error && error.message) || "");
|
|
104
|
+
const retriable = /field_exception|field exception|temporary failure|internal server error/i.test(msg);
|
|
105
|
+
if (!retriable || attempt >= 2) throw error;
|
|
106
|
+
await new Promise((r) => setTimeout(r, 1200 * (attempt + 1)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
async _send(threadID, uploadOptions, options) {
|