@eryxenx/insta-chat 2.0.8 → 2.0.10
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 +47 -0
- package/DOCS.md +16 -0
- package/package.json +3 -3
- package/src/app/InstaClient.js +52 -4
- package/src/constants.js +2 -1
- package/src/events/normalizeEvent.js +8 -0
- package/src/methods/inbox.js +14 -0
- package/src/methods/message.js +17 -8
- package/src/methods/user.js +21 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ 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
|
+
|
|
39
|
+
## [2.0.9] - 2026-10-11
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`unsendMessage` still never deleted the message.** The 2.0.8 fix sent
|
|
44
|
+
`_csrftoken` + `_uuid` to the REST delete endpoint
|
|
45
|
+
(`/api/v1/direct_v2/threads/<t>/items/<id>/delete/`), but Instagram retired
|
|
46
|
+
that endpoint: on `www.instagram.com` the whole `/api/v1` namespace now
|
|
47
|
+
returns `{"message":"useragent mismatch","status":"fail"}` /
|
|
48
|
+
`not-logged-in` 404, and on `i.instagram.com` it needs the mobile app
|
|
49
|
+
identity. Unsend now goes through the web DM client's Relay mutation
|
|
50
|
+
`IGDMessageUnsendDialogOffMsysMutation` (doc_id `26948700068153789`) with
|
|
51
|
+
`{ message_id, send_data: { thread_id } }`, which answers
|
|
52
|
+
`{ data: { direct_unsend_message: true } }`. Verified against a real message.
|
|
53
|
+
|
|
7
54
|
## [2.0.8] - 2026-10-11
|
|
8
55
|
|
|
9
56
|
### Fixed
|
package/DOCS.md
CHANGED
|
@@ -182,6 +182,7 @@ Relay **doc_ids** (stable per friendly-name, rotate rarely):
|
|
|
182
182
|
| `IGDirectCutoutStickerSendMutation` | `26601474479505807` |
|
|
183
183
|
| `PolarisDirectInboxQuery` | `27909866362025854` |
|
|
184
184
|
| `useFeatureLimitsOffMsysQuery` (login probe) | `28374543635529813` |
|
|
185
|
+
| `IGDMessageUnsendDialogOffMsysMutation` (unsend) | `26948700068153789` |
|
|
185
186
|
| Thread detail | `29619996517588618` |
|
|
186
187
|
|
|
187
188
|
### Text send
|
|
@@ -213,6 +214,21 @@ Relay **doc_ids** (stable per friendly-name, rotate rarely):
|
|
|
213
214
|
`reaction_status` is `"created"` or `"deleted"`. (Reverse-engineered from the
|
|
214
215
|
live `comet/CometDirectPageWeb` bundle; the legacy REST reaction endpoint 404s.)
|
|
215
216
|
|
|
217
|
+
### Unsend (delete for everyone)
|
|
218
|
+
|
|
219
|
+
`IGDMessageUnsendDialogOffMsysMutation` (doc_id `26948700068153789`) with
|
|
220
|
+
variables:
|
|
221
|
+
|
|
222
|
+
```javascript
|
|
223
|
+
{ message_id: "<mid.$...>", send_data: { thread_id: "<threadID>" } }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
It answers `{ data: { direct_unsend_message: true } }`. The legacy REST delete
|
|
227
|
+
endpoint (`/api/v1/direct_v2/threads/<t>/items/<id>/delete/`) is gone: on
|
|
228
|
+
`www.instagram.com` the whole `/api/v1` namespace returns `not-logged-in` 404,
|
|
229
|
+
and on `i.instagram.com` it needs the mobile app identity. (Reverse-engineered
|
|
230
|
+
from the live `IGDMessageUnsendDialogOffMsys.react` chunk.)
|
|
231
|
+
|
|
216
232
|
### Session validation
|
|
217
233
|
|
|
218
234
|
The REST probe endpoints lie (they 302 to login even for healthy sessions), so
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/insta-chat",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": " Unofficial Instagram Chat API for Node.js | Build Instagram DM bots on personal accounts
|
|
3
|
+
"version": "2.0.10",
|
|
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",
|
|
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": [
|
package/src/app/InstaClient.js
CHANGED
|
@@ -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.
|
|
346
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -25,7 +25,8 @@ const DOC_ID = {
|
|
|
25
25
|
CUTOUT_STICKER_SEND: "26601474479505807",
|
|
26
26
|
MARK_THREAD_READ: "27399783383056109",
|
|
27
27
|
THREAD_DETAIL: "29619996517588618",
|
|
28
|
-
INBOX: "27909866362025854"
|
|
28
|
+
INBOX: "27909866362025854",
|
|
29
|
+
UNSEND_MESSAGE: "26948700068153789"
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const EP = {
|
|
@@ -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,
|
package/src/methods/inbox.js
CHANGED
|
@@ -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
|
package/src/methods/message.js
CHANGED
|
@@ -99,15 +99,24 @@ class MessageMethods {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
102
|
+
// Unsend (delete for everyone) via the Relay mutation the web DM client uses:
|
|
103
|
+
// `IGDMessageUnsendDialogOffMsysMutation` (doc_id 26948700068153789). The
|
|
104
|
+
// legacy REST delete path (`/direct_v2/threads/<t>/items/<id>/delete/`) is
|
|
105
|
+
// dead on www.instagram.com (the whole /api/v1 namespace 404s) and on
|
|
106
|
+
// i.instagram.com it needs the mobile app identity, so we go straight to
|
|
107
|
+
// GraphQL. Captured live; verified against a real message.
|
|
106
108
|
async unsend(threadID, itemID) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
try {
|
|
110
|
+
const res = await this.http.postGraphQL(
|
|
111
|
+
"IGDMessageUnsendDialogOffMsysMutation",
|
|
112
|
+
DOC_ID.UNSEND_MESSAGE,
|
|
113
|
+
{ message_id: String(itemID), send_data: { thread_id: String(threadID) } },
|
|
114
|
+
`https://www.instagram.com/direct/t/${threadID}/`
|
|
115
|
+
);
|
|
116
|
+
return Boolean(res && res.data && res.data.direct_unsend_message) && !res.errors;
|
|
117
|
+
} catch (_) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
// Instagram lets you edit an outgoing DM within 15 minutes of sending it.
|
package/src/methods/user.js
CHANGED
|
@@ -18,21 +18,35 @@ 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
return match || null;
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
async search(query, limit = 20) {
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
try {
|
|
45
|
+
const res = await this.http.get(EP.USER_SEARCH(query, limit));
|
|
46
|
+
return res?.users || [];
|
|
47
|
+
} catch (_) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
}
|
|
38
52
|
|