@eryxenx/insta-chat 2.0.9 → 2.0.11

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 CHANGED
@@ -4,6 +4,38 @@ All notable changes to `@eryxenx/insta-chat` are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.0.10] - 2026-10-11
8
+
9
+ ### Added
10
+
11
+ - **`event.participantIDs` is populated on Instagram message events.** Facebook
12
+ MQTT events carried the thread's member list, so commands ported from
13
+ GoatBot-Pro read `event.participantIDs` (`all.js` tags every member, `ban.js`
14
+ checks membership). Instagram inbox events carry none, so the normalizer now
15
+ fills it from the participants the client learned from thread introspection
16
+ (`InstaClient.getThreadParticipantIDs`, fed by the native inbox poller's
17
+ per-poll snapshot). It stays `undefined` when unknown — the same shape as
18
+ upstream, so `|| []` guards keep working.
19
+ - **`addUserToGroup` / `removeUserFromGroup` resolve a username or `@handle` to
20
+ a numeric igid** before calling the endpoint, against the offline user index
21
+ first and a live lookup second, and return `false` rather than send a
22
+ non-numeric id Instagram would 4xx on. The frozen `adduser.js` may hand over a
23
+ handle it could not resolve itself.
24
+
25
+ ### Fixed
26
+
27
+ - **A username lookup can no longer masquerade as a dead session.** Both
28
+ `usernameinfo` and `users/search` are `/api/v1` REST paths that Instagram now
29
+ answers with a 302 to the login page for a web session; the HTTP layer
30
+ classifies any 3xx as an auth failure, so `getInfoByUsername` (and `search`)
31
+ used to leak an auth-classified error. They now return `null` / `[]`, letting
32
+ callers fall back to the users learned from thread introspection.
33
+ - **A command that builds an empty reply no longer risks the session.** An empty
34
+ text send already returns `{ messageID: null, dropped: "empty" }`; the ported
35
+ `adduser.js` (now byte-identical to GoatBot-Pro) can produce an empty string
36
+ for a handle it cannot resolve, and the drop guard keeps that from reaching
37
+ Instagram's login-page redirect.
38
+
7
39
  ## [2.0.9] - 2026-10-11
8
40
 
9
41
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eryxenx/insta-chat",
3
- "version": "2.0.9",
4
- "description": " Unofficial Instagram Chat API for Node.js | Build Instagram DM bots on personal accounts \u2014 send messages, media, voice notes, reactions, edit & unsend, listen for incoming chats. No browser at runtime",
3
+ "version": "2.0.11",
4
+ "description": " Unofficial Instagram Chat API for Node.js | Build Instagram DM bots on personal accounts — send messages, media, voice notes, reactions, edit & unsend, listen for incoming chats. No browser at runtime",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "exports": {
@@ -23,7 +23,7 @@
23
23
  "CHANGELOG.md"
24
24
  ],
25
25
  "scripts": {
26
- "test": "node test/smoke.js && node test/antiSuspension.test.js && node test/replyContent.test.js && node test/edit.test.js && node test/sendReply.test.js && node test/client.test.js && node test/relayError.test.js && node test/safetyWiring.test.js && node test/mediaOrder.test.js && node test/sessionSafety.test.js && node test/emptySend.test.js && node test/pollFloor.test.js && node test/unsend.test.js && node test/unsendGraphQL.test.js",
26
+ "test": "node test/smoke.js && node test/antiSuspension.test.js && node test/replyContent.test.js && node test/edit.test.js && node test/sendReply.test.js && node test/client.test.js && node test/relayError.test.js && node test/safetyWiring.test.js && node test/mediaOrder.test.js && node test/sessionSafety.test.js && node test/emptySend.test.js && node test/pollFloor.test.js && node test/userLookup.test.js && node test/unsend.test.js && node test/unsendGraphQL.test.js",
27
27
  "build": "node scripts/build.js"
28
28
  },
29
29
  "keywords": [
@@ -39,6 +39,10 @@ class InstaClient {
39
39
  // lookups (usernameinfo / web_profile_info) for web sessions, so resolving a
40
40
  // mentioned @handle must come from data the session already has.
41
41
  this._userIndex = new Map();
42
+ // threadID -> participant igids, learned from thread introspection. Instagram
43
+ // inbox events carry no participant list, so frozen commands that read
44
+ // event.participantIDs (all, ban) are served from here by the normalizer.
45
+ this._threadMembers = new Map();
42
46
 
43
47
  this._learnFromInbox();
44
48
  loadApiMethods(this);
@@ -146,9 +150,25 @@ class InstaClient {
146
150
  const users = (t.users || []).slice();
147
151
  if (t.viewer) users.unshift(t.viewer);
148
152
  for (const u of users) this._rememberUser(u);
153
+ const threadID = t.id != null ? String(t.id) : t.thread_id != null ? String(t.thread_id) : null;
154
+ if (threadID) this._threadMembers.set(threadID, users.map((u) => String(u.id || u.pk)).filter(Boolean));
149
155
  }
150
156
  }
151
157
 
158
+ // The igids of a thread's participants, from data already seen. Prefers the
159
+ // native poller's latest inbox snapshot (fresh every poll) and falls back to
160
+ // the thread introspection the FCA layer learned. Used to fill
161
+ // event.participantIDs for commands ported from Facebook, whose MQTT events
162
+ // carried the member list but Instagram's do not.
163
+ getThreadParticipantIDs(threadID) {
164
+ if (this.api && typeof this.api.getThreadMemberIDs === "function") {
165
+ const live = this.api.getThreadMemberIDs(threadID);
166
+ if (live.length) return live;
167
+ }
168
+ const members = this._threadMembers.get(String(threadID));
169
+ return Array.isArray(members) ? members.slice() : [];
170
+ }
171
+
152
172
  // Indexes one Instagram user record (inbox/thread shape: id, username,
153
173
  // full_name, interop_messaging_user_fbid, profile_pic_url) under every key a
154
174
  // command may look it up by.
@@ -342,16 +362,44 @@ class InstaClient {
342
362
  async changeAvatar() { return false; }
343
363
 
344
364
  // Instagram group DMs support adding/removing participants. FCA passes
345
- // (userID, threadID); the API takes the reverse. Older native clients may not
346
- // expose the pair, so degrade to a stub instead of throwing.
365
+ // (userID, threadID); the API takes the reverse. Instagram's endpoint only
366
+ // accepts a numeric igid, but frozen commands (adduser.js) may hand over a
367
+ // username or @handle (they resolve numeric ids themselves; only a handle can
368
+ // reach here unresolved). Resolve it against data already learned, and refuse
369
+ // rather than send a non-numeric id the endpoint would 4xx on.
370
+ async _resolveUserKey(userID) {
371
+ const key = String(userID).replace(/^@/, "").trim();
372
+ if (/^\d+$/.test(key)) return key;
373
+ const known = this.getUserByUsername(key);
374
+ if (known) {
375
+ const id = known.id || known.pk || known.interop_messaging_user_fbid;
376
+ if (id != null) return String(id);
377
+ }
378
+ if (typeof this.api.getUserInfoByUsername === "function") {
379
+ try {
380
+ const u = await this.api.getUserInfoByUsername(key);
381
+ if (u) {
382
+ this._rememberUser(u);
383
+ const id = u.id || u.pk || u.interop_messaging_user_fbid;
384
+ if (id != null) return String(id);
385
+ }
386
+ } catch (_) {}
387
+ }
388
+ return null;
389
+ }
390
+
347
391
  async addUserToGroup(userID, threadID) {
348
392
  if (typeof this.api.addUserToGroup !== "function") return false;
349
- return this.api.addUserToGroup(threadID, userID);
393
+ const uid = await this._resolveUserKey(userID);
394
+ if (!uid) return false;
395
+ return this.api.addUserToGroup(threadID, uid);
350
396
  }
351
397
 
352
398
  async removeUserFromGroup(userID, threadID) {
353
399
  if (typeof this.api.removeUserFromGroup !== "function") return false;
354
- return this.api.removeUserFromGroup(threadID, userID);
400
+ const uid = await this._resolveUserKey(userID);
401
+ if (!uid) return false;
402
+ return this.api.removeUserFromGroup(threadID, uid);
355
403
  }
356
404
  }
357
405
 
package/src/constants.js CHANGED
@@ -49,6 +49,7 @@ const EP = {
49
49
  USER_INFO: (userID) => `${BASE}/users/${userID}/info/`,
50
50
  USER_BY_USERNAME: (username) => `${BASE}/users/${username}/usernameinfo/`,
51
51
  USER_SEARCH: (query, count) => `${BASE}/users/search/?q=${encodeURIComponent(query)}&count=${count}`,
52
+ TOPSEARCH: (query) => `${WEB}/web/search/topsearch/?query=${encodeURIComponent(query)}&context=blended`,
52
53
  RUPLOAD_PHOTO: (uploadName) => `https://i.instagram.com/rupload_igphoto/${uploadName}`,
53
54
  RUPLOAD_VIDEO: (uploadName) => `https://i.instagram.com/rupload_igvideo/${uploadName}`,
54
55
  RUPLOAD_AUDIO: (uploadName) => `https://i.instagram.com/rupload_igdirect/${uploadName}`,
@@ -93,6 +93,14 @@ class EventNormalizer {
93
93
  messageID: String(raw.messageID),
94
94
  body,
95
95
  senderName,
96
+ // Facebook MQTT events carried the member list; Instagram's do not. Frozen
97
+ // commands (all.js, ban.js) read event.participantIDs, so fill it from the
98
+ // members the client learned from thread introspection. Undefined when
99
+ // unknown — the same shape as upstream, so `|| []` guards behave.
100
+ participantIDs:
101
+ typeof this.client.getThreadParticipantIDs === "function"
102
+ ? this.client.getThreadParticipantIDs(threadID)
103
+ : undefined,
96
104
  mentions: body.includes("@") ? await this._resolveMentions(body) : {},
97
105
  attachments: (raw.attachments || []).map((a) => ({
98
106
  type: a.type,
@@ -35,6 +35,10 @@ class InboxMethods {
35
35
  this._seen = new Map();
36
36
  this._pollTimer = null;
37
37
  this._selfFbid = null;
38
+ // threadID -> participant igids from the latest inbox snapshot. The inbox
39
+ // event carries no participant list, so the FCA layer reads it from here to
40
+ // fill event.participantIDs for commands ported from Facebook (all, ban).
41
+ this._members = new Map();
38
42
  // threadID -> { title, image, members:Set, adminIDs:Set, nicknames:{} }
39
43
  this._threadState = new Map();
40
44
  this._statePrimed = false;
@@ -58,9 +62,19 @@ class InboxMethods {
58
62
  const fbid = t.viewer?.interop_messaging_user_fbid;
59
63
  if (fbid && !this._selfFbid) this._selfFbid = String(fbid);
60
64
  }
65
+ for (const t of threads) {
66
+ const tid = String(t.id || t.thread_id);
67
+ if (tid) this._members.set(tid, (t.users || []).map((u) => String(u.id || u.pk)).filter(Boolean));
68
+ }
61
69
  return { threads };
62
70
  }
63
71
 
72
+ // Participant igids for a thread, from the latest inbox snapshot.
73
+ getThreadMemberIDs(threadID) {
74
+ const members = this._members.get(String(threadID));
75
+ return Array.isArray(members) ? members.slice() : [];
76
+ }
77
+
64
78
  // The Polaris inbox returns messages under thread.slide_messages as a Relay
65
79
  // connection ({ edges: [{ node }] }); the node carries sender_fbid,
66
80
  // content_type, text_body and content. Older captures used thread.items, so
@@ -18,21 +18,46 @@ class UserMethods {
18
18
  }
19
19
 
20
20
  async getInfoByUsername(username) {
21
+ // Both endpoints are mobile/web REST paths under /api/v1, which Instagram
22
+ // now answers with a 302 to the login page for a web session (the HTTP
23
+ // layer classifies any 3xx as an auth failure). A username lookup must not
24
+ // leak an auth-classified error — a command reading a handle would then look
25
+ // like a dead session. Return null instead; callers fall back to the users
26
+ // the client already learned from thread introspection.
21
27
  try {
22
28
  const res = await this.http.get(EP.USER_BY_USERNAME(username));
23
29
  if (res?.user) return res.user;
24
30
  } catch (_) {}
25
31
 
26
- const res = await this.http.get(EP.USER_SEARCH(username, 30));
27
- const match = (res?.users || []).find(
28
- (u) => u.username?.toLowerCase() === username.toLowerCase()
29
- );
30
- return match || null;
32
+ try {
33
+ const res = await this.http.get(EP.USER_SEARCH(username, 30));
34
+ const match = (res?.users || []).find(
35
+ (u) => u.username?.toLowerCase() === username.toLowerCase()
36
+ );
37
+ if (match) return match;
38
+ } catch (_) {}
39
+
40
+ // web/search/topsearch is the path a logged-in web session actually gets an
41
+ // answer from; the /api/v1 users/* REST paths above are 302'd for web. It
42
+ // returns an exact-match user object (with pk) at users[].user.
43
+ try {
44
+ const res = await this.http.get(EP.TOPSEARCH(username));
45
+ const match = (res?.users || [])
46
+ .map((entry) => entry.user || entry)
47
+ .find((u) => u?.username?.toLowerCase() === username.toLowerCase());
48
+ return match || null;
49
+ } catch (_) {
50
+ return null;
51
+ }
31
52
  }
32
53
 
33
54
  async search(query, limit = 20) {
34
- const res = await this.http.get(EP.USER_SEARCH(query, limit));
35
- return res?.users || [];
55
+ try {
56
+ const res = await this.http.get(EP.USER_SEARCH(query, limit));
57
+ return res?.users || [];
58
+ } catch (_) {
59
+ return [];
60
+ }
36
61
  }
37
62
  }
38
63