@eryxenx/insta-chat 2.0.10 → 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.
@@ -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;
@@ -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,11 @@ 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)}`,
61
+ TOPSEARCH: (query) => `${WEB}/web/search/topsearch/?query=${encodeURIComponent(query)}&context=blended`,
52
62
  RUPLOAD_PHOTO: (uploadName) => `https://i.instagram.com/rupload_igphoto/${uploadName}`,
53
63
  RUPLOAD_VIDEO: (uploadName) => `https://i.instagram.com/rupload_igvideo/${uploadName}`,
54
64
  RUPLOAD_AUDIO: (uploadName) => `https://i.instagram.com/rupload_igdirect/${uploadName}`,
@@ -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
- listen(callback, intervalMs = 20000) {
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
  }