@eryxenx/insta-chat 2.1.3 → 2.2.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 +37 -0
- package/index.js +2 -0
- package/package.json +2 -2
- package/src/api.js +38 -13
- package/src/app/InstaClient.js +16 -0
- package/src/events/normalizeEvent.js +18 -5
- package/src/methods/inbox.js +39 -0
- package/src/realtime/bridge.js +18 -5
- package/src/realtime/listener.js +32 -7
- package/src/realtime/reconcile.js +135 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,43 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and uses
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.2.0] - 2026-11-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Push/poller id reconciliation (`src/realtime/reconcile.js`).** The realtime
|
|
14
|
+
push and the inbox poll name the same DM differently — the poller uses the
|
|
15
|
+
message mid (`mid.$...`), the push carries the numeric `item_id` and, when the
|
|
16
|
+
broker includes it, the same mid. `buildKeys()` returns every key a message can
|
|
17
|
+
be recognised by and `MessageDeduper` drops the second copy, so both paths can
|
|
18
|
+
run at once without the bot replying twice. Exposed as
|
|
19
|
+
`require("@eryxenx/insta-chat").reconcile`.
|
|
20
|
+
- **`InboxMethods.startSafetyPoll(callback, intervalMs)`.** Runs the inbox poll on
|
|
21
|
+
its own timer with its own 429 backoff, independently of the realtime listener,
|
|
22
|
+
so a caller can keep a warm polling fallback under a push path. Returns a cancel
|
|
23
|
+
function. With realtime on, `listen()` now uses it internally instead of relying
|
|
24
|
+
on the pre-subscribe fallback alone.
|
|
25
|
+
- **`InstaClient.resolveThreadID()` / `InboxMethods.getThreadIdMap()`.** The push
|
|
26
|
+
names a thread by its internal `thread_id`; the poller, thread-detail read and
|
|
27
|
+
commands use the inbox id. The map is learned from every inbox snapshot so a
|
|
28
|
+
pushed event can be rewritten to the id the rest of the bot uses.
|
|
29
|
+
- **`bridge` and `reconcile` are exported** from the package root for consumers
|
|
30
|
+
that build their own listener.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **One MQTT connection per session, shared by every consumer.** Instagram's
|
|
35
|
+
broker routes the Direct message-sync stream to a single subscriber per device
|
|
36
|
+
id, so a second connection with the same device id silently takes the stream
|
|
37
|
+
away from the first — the "connects and subscribes but receives nothing"
|
|
38
|
+
deafness. `realtimeConnect()` and `sharedRealtimeClient()` now return the same
|
|
39
|
+
live client and serialise concurrent connects, so the inbox listener and the
|
|
40
|
+
reaction push ride one connection instead of two.
|
|
41
|
+
- **`normalizeEvent` no longer blocks on a live `getUserInfo()`.** The sender-name
|
|
42
|
+
lookup used to make a rate-limited REST round trip on the critical path before
|
|
43
|
+
every command. It now prefers the name the client already learned from the
|
|
44
|
+
inbox/thread views and only falls back to the live call.
|
|
45
|
+
|
|
9
46
|
## [2.1.2] - 2026-11-06
|
|
10
47
|
|
|
11
48
|
### Fixed
|
package/index.js
CHANGED
|
@@ -113,5 +113,7 @@ module.exports.constants = constants;
|
|
|
113
113
|
module.exports.moduleLogin = moduleLogin;
|
|
114
114
|
module.exports.RealtimeClient = RealtimeClient;
|
|
115
115
|
module.exports.RealtimeListener = RealtimeListener;
|
|
116
|
+
module.exports.reconcile = require("./src/realtime/reconcile");
|
|
117
|
+
module.exports.bridge = require("./src/realtime/bridge");
|
|
116
118
|
module.exports.antiSuspension = require("./src/utils/antiSuspension");
|
|
117
119
|
module.exports.sessionGuard = require("./src/utils/sessionGuard");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/insta-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
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",
|
|
@@ -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/userLookup.test.js && node test/profile.test.js && node test/unsend.test.js && node test/unsendGraphQL.test.js && node test/realtime.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/profile.test.js && node test/unsend.test.js && node test/unsendGraphQL.test.js && node test/realtime.test.js && node test/reconcile.test.js && node test/hybridSafetyPoll.test.js",
|
|
27
27
|
"build": "node scripts/build.js"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|
package/src/api.js
CHANGED
|
@@ -51,7 +51,13 @@ class InstaAPI {
|
|
|
51
51
|
this.inbox = new InboxMethods(this.http, () => this.userID, {
|
|
52
52
|
minPollIntervalMs: options.minPollIntervalMs,
|
|
53
53
|
identity: this.identity,
|
|
54
|
-
realtime: options.realtime
|
|
54
|
+
realtime: options.realtime,
|
|
55
|
+
// Both the inbox listener and a caller that wants reaction/typing push
|
|
56
|
+
// (e.g. a bot's reaction watcher) must share one MQTT connection: the
|
|
57
|
+
// broker delivers the Direct message-sync stream to only the most-recent
|
|
58
|
+
// subscriber on a session, so a second connection with the same device id
|
|
59
|
+
// silently steals the stream from the first.
|
|
60
|
+
getSharedClient: () => this.sharedRealtimeClient()
|
|
55
61
|
});
|
|
56
62
|
this.media = new MediaMethods(this.http, this.identity);
|
|
57
63
|
|
|
@@ -310,6 +316,11 @@ class InstaAPI {
|
|
|
310
316
|
return this.inbox.getInbox(cursor);
|
|
311
317
|
}
|
|
312
318
|
|
|
319
|
+
// internal thread_id -> inbox thread id, learned from every inbox snapshot.
|
|
320
|
+
getThreadIdMap() {
|
|
321
|
+
return this.inbox.getThreadIdMap();
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
listen(callback, intervalMs) {
|
|
314
325
|
return this.inbox.listen(callback, intervalMs || this.options.pollInterval);
|
|
315
326
|
}
|
|
@@ -338,19 +349,33 @@ class InstaAPI {
|
|
|
338
349
|
}
|
|
339
350
|
|
|
340
351
|
async realtimeConnect(options = {}) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
352
|
+
return this.sharedRealtimeClient(options);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The one MQTT client a session may keep open. Instagram's broker routes the
|
|
356
|
+
// Direct message-sync stream to a single subscriber per device id, so a second
|
|
357
|
+
// connection with the same id silently takes the stream away from the first —
|
|
358
|
+
// the "connects but receives nothing" deafness. Every internal consumer
|
|
359
|
+
// (listen(), the reaction push) shares this client instead of dialing its own.
|
|
360
|
+
// Rebuilt only when the previous one is gone or dropped. Concurrent callers
|
|
361
|
+
// (the listener and the watcher start together) share one connect().
|
|
362
|
+
async sharedRealtimeClient(options = {}) {
|
|
363
|
+
if (this._realtime && this._realtime.connected) return this._realtime;
|
|
364
|
+
if (this._realtimeConnecting) return this._realtimeConnecting;
|
|
365
|
+
if (this._realtime) {
|
|
366
|
+
try { this._realtime.disconnect(); } catch (_) {}
|
|
351
367
|
}
|
|
352
|
-
|
|
353
|
-
|
|
368
|
+
const client = this.realtimeClient(options);
|
|
369
|
+
this._realtime = client;
|
|
370
|
+
this._realtimeConnecting = (async () => {
|
|
371
|
+
try {
|
|
372
|
+
await client.connect();
|
|
373
|
+
return client;
|
|
374
|
+
} finally {
|
|
375
|
+
this._realtimeConnecting = null;
|
|
376
|
+
}
|
|
377
|
+
})();
|
|
378
|
+
return this._realtimeConnecting;
|
|
354
379
|
}
|
|
355
380
|
|
|
356
381
|
realtimeOn(event, handler) {
|
package/src/app/InstaClient.js
CHANGED
|
@@ -169,6 +169,22 @@ class InstaClient {
|
|
|
169
169
|
return Array.isArray(members) ? members.slice() : [];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// The realtime push names a thread by its internal thread_id; the poller,
|
|
173
|
+
// thread-detail read and frozen commands use the inbox id. Map one onto the
|
|
174
|
+
// other so a pushed event is handled with the same thread id the rest of the
|
|
175
|
+
// bot uses. An unknown id (the map is cold, or a brand-new thread) is returned
|
|
176
|
+
// unchanged.
|
|
177
|
+
resolveThreadID(threadID) {
|
|
178
|
+
const key = String(threadID);
|
|
179
|
+
if (this.api && typeof this.api.getThreadIdMap === "function") {
|
|
180
|
+
const mapped = this.api.getThreadIdMap().get(key);
|
|
181
|
+
if (mapped) return String(mapped);
|
|
182
|
+
}
|
|
183
|
+
const known = this._threadMembers.get(key);
|
|
184
|
+
if (known) return key;
|
|
185
|
+
return key;
|
|
186
|
+
}
|
|
187
|
+
|
|
172
188
|
// Indexes one Instagram user record (inbox/thread shape: id, username,
|
|
173
189
|
// full_name, interop_messaging_user_fbid, profile_pic_url) under every key a
|
|
174
190
|
// command may look it up by.
|
|
@@ -79,11 +79,24 @@ class EventNormalizer {
|
|
|
79
79
|
const senderID = String(raw.senderID || "");
|
|
80
80
|
const body = raw.body || "";
|
|
81
81
|
|
|
82
|
-
let senderName =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
let senderName = raw.senderName || null;
|
|
83
|
+
// Prefer the name the client already learned from the inbox/thread views.
|
|
84
|
+
// A live getUserInfo() on the critical path is a web REST call Instagram
|
|
85
|
+
// answers with a 302/429 for this session, so it costs a round trip and then
|
|
86
|
+
// fails — and the command dispatch waits on it. The offline index is free.
|
|
87
|
+
if (!senderName && typeof this.client.getUserByUsername === "function") {
|
|
88
|
+
try {
|
|
89
|
+
const known = this.client.getUserByUsername(senderID);
|
|
90
|
+
if (known) senderName = known.full_name || known.username || null;
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
}
|
|
93
|
+
if (!senderName) {
|
|
94
|
+
try {
|
|
95
|
+
const u = await this.client.api.getUserInfo(senderID);
|
|
96
|
+
if (u) senderName = u.full_name || u.username || null;
|
|
97
|
+
} catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
if (!senderName) senderName = senderID;
|
|
87
100
|
|
|
88
101
|
const event = {
|
|
89
102
|
type: "message",
|
package/src/methods/inbox.js
CHANGED
|
@@ -31,12 +31,17 @@ class InboxMethods {
|
|
|
31
31
|
this.identity = options.identity || {};
|
|
32
32
|
// { enabled, fallback } — realtime is opt-in via listen(); see listen().
|
|
33
33
|
this.realtimeOptions = options.realtime || null;
|
|
34
|
+
// Returns the session's single shared MQTT client (see InstaAPI). When
|
|
35
|
+
// present, listen() rides that connection instead of dialing its own, so a
|
|
36
|
+
// second consumer cannot steal the Direct stream from it.
|
|
37
|
+
this.getSharedClient = typeof options.getSharedClient === "function" ? options.getSharedClient : null;
|
|
34
38
|
// Lowest poll interval listen() will honour, in ms. Instagram has no DM
|
|
35
39
|
// push, so reply latency is bounded by one poll cycle. The 15000 default is
|
|
36
40
|
// the conservative safe floor for a personal account; operators on a
|
|
37
41
|
// residential/mobile proxy can lower it (e.g. 2000) for snappier replies.
|
|
38
42
|
this.minPollIntervalMs = Math.max(2000, Number(options.minPollIntervalMs) || 15000);
|
|
39
43
|
this._seen = new Map();
|
|
44
|
+
this._threadIdMap = new Map();
|
|
40
45
|
this._pollTimer = null;
|
|
41
46
|
this._selfFbid = null;
|
|
42
47
|
// threadID -> participant igids from the latest inbox snapshot. The inbox
|
|
@@ -70,9 +75,28 @@ class InboxMethods {
|
|
|
70
75
|
const tid = String(t.id || t.thread_id);
|
|
71
76
|
if (tid) this._members.set(tid, (t.users || []).map((u) => String(u.id || u.pk)).filter(Boolean));
|
|
72
77
|
}
|
|
78
|
+
this._learnThreadIds(threads);
|
|
73
79
|
return { threads };
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// The realtime push names a thread by its internal `thread_id` (e.g.
|
|
83
|
+
// 340282366841710301244259277549563001902) while the poller, the thread-detail
|
|
84
|
+
// read and the frozen commands all use the inbox id (e.g. 1102531985914926).
|
|
85
|
+
// Learn the mapping from every inbox snapshot so the listener can hand the rest
|
|
86
|
+
// of the bot one consistent thread id.
|
|
87
|
+
_learnThreadIds(threads) {
|
|
88
|
+
for (const t of threads) {
|
|
89
|
+
const internal = t.thread_id != null ? String(t.thread_id) : null;
|
|
90
|
+
const inbox = t.id != null ? String(t.id) : (t.thread_fbid != null ? String(t.thread_fbid) : null);
|
|
91
|
+
if (internal && inbox) this._threadIdMap.set(internal, inbox);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// internal thread_id -> inbox thread id, from the latest inbox snapshot.
|
|
96
|
+
getThreadIdMap() {
|
|
97
|
+
return new Map(this._threadIdMap);
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
// Participant igids for a thread, from the latest inbox snapshot.
|
|
77
101
|
getThreadMemberIDs(threadID) {
|
|
78
102
|
const members = this._members.get(String(threadID));
|
|
@@ -357,6 +381,11 @@ class InboxMethods {
|
|
|
357
381
|
seqId: rtOpts.seqId,
|
|
358
382
|
snapshotAtMs: rtOpts.snapshotAtMs,
|
|
359
383
|
getRealtimeState: rtOpts.getRealtimeState || (() => this.fetchRealtimeState()),
|
|
384
|
+
// Share the session's single MQTT connection. A second connection on
|
|
385
|
+
// the same device id steals the Direct stream from the first, so the
|
|
386
|
+
// inbox listener and any other consumer (reaction push) must ride the
|
|
387
|
+
// same client.
|
|
388
|
+
getClient: rtOpts.getClient || (this.getSharedClient ? () => this.getSharedClient() : undefined),
|
|
360
389
|
onReady: rtOpts.onReady,
|
|
361
390
|
onError: (err) => {
|
|
362
391
|
// Before the subscription is confirmed a failure means realtime is
|
|
@@ -386,6 +415,16 @@ class InboxMethods {
|
|
|
386
415
|
return this._stopListen;
|
|
387
416
|
}
|
|
388
417
|
|
|
418
|
+
// Runs the inbox poller as an independent stream, for a caller that uses
|
|
419
|
+
// realtime push as the primary path and wants a polling safety net beneath it
|
|
420
|
+
// (the broker can connect, subscribe, and then silently stop delivering). The
|
|
421
|
+
// poller keeps its own cursor and its own rate-limit backoff, so a 429 here
|
|
422
|
+
// does not disturb the push path. Returns a stop function. The caller is
|
|
423
|
+
// expected to dedupe events that both sources deliver.
|
|
424
|
+
startSafetyPoll(callback, intervalMs) {
|
|
425
|
+
return this._startPolling(callback, intervalMs);
|
|
426
|
+
}
|
|
427
|
+
|
|
389
428
|
// The iris (Direct message-sync) subscription cursor: seq_id + snapshot time.
|
|
390
429
|
// Read from the inbox query when it exposes them; otherwise the listener
|
|
391
430
|
// connects without a subscription (a live message still opens the stream).
|
package/src/realtime/bridge.js
CHANGED
|
@@ -37,21 +37,32 @@ function rawEventFromMessageSync(event, selfId) {
|
|
|
37
37
|
if (!match || !isMessagePatch(message.path, message.op)) return null;
|
|
38
38
|
|
|
39
39
|
const threadID = String(match[1]);
|
|
40
|
-
const
|
|
40
|
+
const itemID = String(message.item_id || match[2]);
|
|
41
|
+
// The poller names a message by its mid ("mid.$..."); the push carries the
|
|
42
|
+
// numeric item id and, when the broker includes it, the same mid. Prefer the
|
|
43
|
+
// mid so both sources produce the same messageID and a dedupe on it works.
|
|
44
|
+
const mid = message.message_id != null ? String(message.message_id) : null;
|
|
45
|
+
const messageID = mid || itemID;
|
|
41
46
|
|
|
42
47
|
const senderID = message.user_id != null ? String(message.user_id) : null;
|
|
43
48
|
// The broker echoes our own sends back over message-sync; drop them.
|
|
44
49
|
if (senderID && selfId && senderID === String(selfId)) return null;
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
// item_type is a number on the wire (text = 1) and a string elsewhere; treat
|
|
52
|
+
// only the known non-message kinds as non-actionable and everything else as
|
|
53
|
+
// text, so a numeric type is not misread as an attachment with no URL.
|
|
54
|
+
const rawType = message.item_type != null ? message.item_type : message.content_type;
|
|
55
|
+
const typeText = typeof rawType === "string" ? rawType.toLowerCase() : String(rawType ?? "");
|
|
56
|
+
if (/reaction|action_log|like|video_call/.test(typeText)) return null;
|
|
57
|
+
const isText = typeText === "" || typeText === "1" || typeText === "text" || typeText === "text_content";
|
|
58
|
+
const itemType = isText ? "text" : typeText;
|
|
48
59
|
|
|
49
60
|
const body = message.text || message.text_body || "";
|
|
50
61
|
let attachments = [];
|
|
51
|
-
if (
|
|
62
|
+
if (!isText) {
|
|
52
63
|
attachments = attachmentsFromContent(message.media || message.content || message);
|
|
53
64
|
}
|
|
54
|
-
if (
|
|
65
|
+
if (!isText && attachments.length === 0) {
|
|
55
66
|
// The item carried no parseable media URL; let the normalizer/bot fetch the
|
|
56
67
|
// thread history instead of emitting a message with nothing actionable.
|
|
57
68
|
return null;
|
|
@@ -62,6 +73,8 @@ function rawEventFromMessageSync(event, selfId) {
|
|
|
62
73
|
threadID,
|
|
63
74
|
senderID,
|
|
64
75
|
messageID,
|
|
76
|
+
itemID,
|
|
77
|
+
mid,
|
|
65
78
|
body,
|
|
66
79
|
attachments,
|
|
67
80
|
itemType,
|
package/src/realtime/listener.js
CHANGED
|
@@ -55,15 +55,32 @@ class RealtimeListener {
|
|
|
55
55
|
|
|
56
56
|
async start(callback) {
|
|
57
57
|
this._callback = callback;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// Prefer a caller-supplied client (the session's single shared MQTT
|
|
59
|
+
// connection). A second connection on the same device id silently steals the
|
|
60
|
+
// Direct message-sync stream from the first, so when one is offered we attach
|
|
61
|
+
// to it rather than dialing our own.
|
|
62
|
+
let owned = true;
|
|
63
|
+
if (typeof this.options.getClient === "function") {
|
|
64
|
+
this._client = await this.options.getClient();
|
|
65
|
+
owned = false;
|
|
66
|
+
} else {
|
|
67
|
+
this._client = new RealtimeClient(this.context, this.options);
|
|
68
|
+
}
|
|
69
|
+
this._owned = owned;
|
|
70
|
+
|
|
71
|
+
// Keep per-instance handler references so stop() can detach exactly its own
|
|
72
|
+
// listeners from a shared client without disturbing the other consumers.
|
|
73
|
+
this._onMessage = (event) => this._handleMessageSync(event);
|
|
74
|
+
this._onError = (err) => this._emitError(err);
|
|
75
|
+
this._onDisconnected = () => {
|
|
62
76
|
this.connected = false;
|
|
63
77
|
if (!this._stopped) this._emitError(new Error("Realtime MQTT connection closed"));
|
|
64
|
-
}
|
|
78
|
+
};
|
|
79
|
+
this._client.on("message", this._onMessage);
|
|
80
|
+
this._client.on("error", this._onError);
|
|
81
|
+
this._client.on("disconnected", this._onDisconnected);
|
|
65
82
|
|
|
66
|
-
await this._client.connect();
|
|
83
|
+
if (!this._client.connected) await this._client.connect();
|
|
67
84
|
this.connected = true;
|
|
68
85
|
|
|
69
86
|
const cursor = await this._resolveCursor();
|
|
@@ -79,7 +96,15 @@ class RealtimeListener {
|
|
|
79
96
|
stop() {
|
|
80
97
|
this._stopped = true;
|
|
81
98
|
this.connected = false;
|
|
82
|
-
if (this._client)
|
|
99
|
+
if (!this._client) return;
|
|
100
|
+
if (typeof this._client.removeListener === "function") {
|
|
101
|
+
if (this._onMessage) this._client.removeListener("message", this._onMessage);
|
|
102
|
+
if (this._onError) this._client.removeListener("error", this._onError);
|
|
103
|
+
if (this._onDisconnected) this._client.removeListener("disconnected", this._onDisconnected);
|
|
104
|
+
}
|
|
105
|
+
// Only tear down a connection this listener owns; a shared client belongs to
|
|
106
|
+
// the session and other consumers may still be using it.
|
|
107
|
+
if (this._owned !== false) this._client.disconnect();
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
// Id reconciliation between the two inbound sources.
|
|
9
|
+
//
|
|
10
|
+
// A Direct message reaches the bot twice: once on the realtime MQTT push (fast,
|
|
11
|
+
// ~1s) and once on the next inbox poll (the safety net). The two name the same
|
|
12
|
+
// message differently:
|
|
13
|
+
//
|
|
14
|
+
// poller node.id = "mid.$cAH8..." (Instagram's mid)
|
|
15
|
+
// push item value.item_id = "330934783..." (the numeric item id)
|
|
16
|
+
// push item value.message_id = "mid.$..." (the mid, when the broker sends it)
|
|
17
|
+
//
|
|
18
|
+
// So a dedupe keyed on one id alone would treat the two copies as two messages
|
|
19
|
+
// and the bot would reply twice. buildKeys() returns every key a message can be
|
|
20
|
+
// recognised by, and MessageDeduper treats two arrivals as the same message when
|
|
21
|
+
// any key matches. The content fingerprint (thread + sender + body + a coarse
|
|
22
|
+
// timestamp bucket) is the fallback for a push payload that omits the mid.
|
|
23
|
+
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
const crypto = require("crypto");
|
|
27
|
+
|
|
28
|
+
const MID_RE = /^mid\./;
|
|
29
|
+
// Instagram timestamps appear in ms (13 digits) and microseconds (16 digits).
|
|
30
|
+
function toMillis(ts) {
|
|
31
|
+
const n = Number(ts);
|
|
32
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
33
|
+
return n > 1e14 ? Math.round(n / 1000) : Math.round(n);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sha1(text) {
|
|
37
|
+
return crypto.createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The mid, from whichever field the source used. A poller node exposes it as
|
|
41
|
+
// `id`; a push item may expose `message_id`, `mid` or an item_id shaped like one.
|
|
42
|
+
function extractMid(source = {}) {
|
|
43
|
+
const candidates = [source.messageID, source.message_id, source.mid, source.item_id, source.itemID, source.id];
|
|
44
|
+
for (const c of candidates) {
|
|
45
|
+
if (c != null && MID_RE.test(String(c))) return String(c);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The numeric item id, from whichever field the source used.
|
|
51
|
+
function extractItemId(source = {}) {
|
|
52
|
+
const candidates = [source.item_id, source.itemID, source.id, source.messageID, source.message_id];
|
|
53
|
+
for (const c of candidates) {
|
|
54
|
+
if (c != null && /^\d+$/.test(String(c))) return String(c);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every key an inbound message can be recognised by. `timestamp` may be in ms or
|
|
60
|
+
// microseconds; the fingerprint bucket normalises it and tolerates ~1s of skew
|
|
61
|
+
// between the push and the poll.
|
|
62
|
+
function buildKeys(msg = {}) {
|
|
63
|
+
const keys = new Set();
|
|
64
|
+
const threadID = msg.threadID != null ? String(msg.threadID) : "";
|
|
65
|
+
const senderID = msg.senderID != null ? String(msg.senderID) : "";
|
|
66
|
+
|
|
67
|
+
const mid = extractMid(msg);
|
|
68
|
+
if (mid) keys.add(`mid:${mid}`);
|
|
69
|
+
const itemID = extractItemId(msg);
|
|
70
|
+
if (itemID) keys.add(`item:${itemID}`);
|
|
71
|
+
if (msg.offlineThreadingId != null && String(msg.offlineThreadingId)) {
|
|
72
|
+
keys.add(`otd:${msg.offlineThreadingId}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Content fingerprint, only when there is a sender and a body: two genuinely
|
|
76
|
+
// different messages from the same sender rarely share a body in the same
|
|
77
|
+
// second, and without it a push that carries no mid could never be matched to
|
|
78
|
+
// its poller copy.
|
|
79
|
+
const body = msg.body == null ? "" : String(msg.body);
|
|
80
|
+
if (threadID && senderID && body) {
|
|
81
|
+
const bucket = Math.floor(toMillis(msg.timestamp) / 1000);
|
|
82
|
+
keys.add(`fp:${sha1(`${threadID}|${senderID}|${body}|${bucket}`)}`);
|
|
83
|
+
}
|
|
84
|
+
return keys;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sliding window of recently-seen message keys. Bounded so a long-running bot
|
|
88
|
+
// does not grow it without limit.
|
|
89
|
+
class MessageDeduper {
|
|
90
|
+
constructor({ ttlMs = 10 * 60 * 1000, max = 5000 } = {}) {
|
|
91
|
+
this.ttlMs = ttlMs;
|
|
92
|
+
this.max = max;
|
|
93
|
+
this._seen = new Map(); // key -> seenAt
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_prune(now) {
|
|
97
|
+
for (const [key, at] of this._seen) {
|
|
98
|
+
if (now - at > this.ttlMs) this._seen.delete(key);
|
|
99
|
+
}
|
|
100
|
+
while (this._seen.size > this.max) {
|
|
101
|
+
const oldest = this._seen.keys().next().value;
|
|
102
|
+
this._seen.delete(oldest);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
has(keys) {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
this._prune(now);
|
|
109
|
+
for (const key of keys) if (this._seen.has(key)) return true;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
add(keys) {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
for (const key of keys) this._seen.set(key, now);
|
|
116
|
+
this._prune(now);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// true when the message is new (and records it), false when it is a duplicate.
|
|
120
|
+
accept(keys) {
|
|
121
|
+
if (this.has(keys)) return false;
|
|
122
|
+
this.add(keys);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clear() {
|
|
127
|
+
this._seen.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get size() {
|
|
131
|
+
return this._seen.size;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { buildKeys, extractMid, extractItemId, toMillis, MessageDeduper };
|