@eryxenx/insta-chat 2.1.0 → 2.1.2
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 +30 -0
- package/DOCS.md +2 -2
- package/README.md +15 -1
- package/package.json +1 -1
- package/src/api.js +11 -1
- package/src/methods/media.js +28 -14
- package/src/realtime/client.js +24 -4
- package/src/realtime/mqttot.js +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and uses
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.1.2] - 2026-11-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Realtime MQTT no longer flaps.** A dropped broker connection could never be
|
|
14
|
+
brought back up: `SocketMQTToTTransport.connect()` kept the previous socket's
|
|
15
|
+
`_closed`/`_error` state, so every `recvPacket()` on a reconnect rejected with
|
|
16
|
+
the stale `MQTT socket closed` and the client reconnected in a tight loop
|
|
17
|
+
(`realtime: MQTT transport closed` → `realtime reaction push connected` →
|
|
18
|
+
`MQTT transport closed`). The transport now resets that state on connect.
|
|
19
|
+
- **Keep-alive matches the ping interval.** The CONNECT advertised a 20 s
|
|
20
|
+
keep-alive while the PINGREQ timer fired every 30 s, so the broker dropped the
|
|
21
|
+
socket after ~30 s of apparent silence. The keep-alive (60 s) and ping interval
|
|
22
|
+
(25 s) now share the `KEEP_ALIVE_SECONDS` / `PING_INTERVAL_MS` constants, with
|
|
23
|
+
the ping comfortably inside the window.
|
|
24
|
+
- **`realtimeConnect()` returns a live client.** After a drop it handed back the
|
|
25
|
+
stale, disconnected client, so a caller that "reconnected" was still holding a
|
|
26
|
+
dead socket. It now reuses the client only while `connected` and otherwise
|
|
27
|
+
builds a fresh one. Callers should attach handlers to the returned client (see
|
|
28
|
+
`README`); a reconnect can yield a new instance.
|
|
29
|
+
- **`disconnect()` no longer leaks an unhandled rejection.** The teardown write
|
|
30
|
+
(`DISCONNECT` packet) is cancelled when the socket closes and its rejection
|
|
31
|
+
(`write ECANCELED Canceled because of SSL destruction`) was unhandled, which
|
|
32
|
+
could crash the process on every disconnect. It is now swallowed.
|
|
33
|
+
- **No duplicate ping timers.** `connect()` clears any previous ping timer, so a
|
|
34
|
+
reconnect does not leave the old one running alongside the new one.
|
|
35
|
+
- `test/realtime.test.js` now awaits its async sections (they previously resolved
|
|
36
|
+
after the process had already printed its result, so their assertions were
|
|
37
|
+
never counted) and covers the reset, reconnect, keep-alive and teardown paths.
|
|
38
|
+
|
|
9
39
|
## [2.1.0] - 2026-10-14
|
|
10
40
|
|
|
11
41
|
### Added
|
package/DOCS.md
CHANGED
|
@@ -49,7 +49,7 @@ commands call). Pass `{ native: true }` to get the bare `InstaAPI` instead.
|
|
|
49
49
|
| `deviceFile` | `./device.json` | Persisted device identity. |
|
|
50
50
|
| `pollInterval` | `15000` | `listen()` poll interval (ms), used when realtime is off. |
|
|
51
51
|
| `minPollIntervalMs` | `15000` | Lowest interval `listen()` will honour (floor, min 2000). |
|
|
52
|
-
| `realtime` | – | `{ enabled, fallback, host, keepAliveMs }` — realtime MQTT push for `listen()`. |
|
|
52
|
+
| `realtime` | – | `{ enabled, fallback, host, keepAliveMs }` — realtime MQTT push for `listen()`. `keepAliveMs` (default 25000) is the PINGREQ interval and must stay below the CONNECT keep-alive (60 s). |
|
|
53
53
|
|
|
54
54
|
## `InstaClient` (FCA surface)
|
|
55
55
|
|
|
@@ -176,7 +176,7 @@ into the same raw inbox events the poller produces and normalized identically.
|
|
|
176
176
|
| Method | Description |
|
|
177
177
|
|--------|-------------|
|
|
178
178
|
| `realtimeClient(options?)` | Build a raw `RealtimeClient` without connecting. |
|
|
179
|
-
| `realtimeConnect(options?)` | Open the MQTT connection. Resolves to the client. |
|
|
179
|
+
| `realtimeConnect(options?)` | Open the MQTT connection. Resolves to the client. Reuses the client only while connected; after a drop it builds a fresh one, so re-attach handlers to whatever it returns. |
|
|
180
180
|
| `realtimeOn(event, handler)` | Subscribe to `message`, `typing`, `seen`, `presence`, `direct`, `receive`, `error`, `disconnected`. |
|
|
181
181
|
| `realtimeSubscribe()` | Subscribe to the Direct message-sync stream (reads the cursor from the inbox query). |
|
|
182
182
|
| `realtimePing()` | Send a keepalive ping. |
|
package/README.md
CHANGED
|
@@ -121,7 +121,8 @@ const api = await login(cookie, { realtime: { enabled: true } });
|
|
|
121
121
|
|
|
122
122
|
`realtime` is `{ enabled, fallback, host, keepAliveMs }`. With `fallback !== false`
|
|
123
123
|
(the default) a broker that will not connect degrades to the inbox poller, so the
|
|
124
|
-
bot still gets events.
|
|
124
|
+
bot still gets events. `keepAliveMs` is the PINGREQ interval; it must stay below
|
|
125
|
+
the CONNECT keep-alive (60 s) and defaults to 25 s. See [Listening](#listening).
|
|
125
126
|
|
|
126
127
|
### Two layers
|
|
127
128
|
|
|
@@ -262,6 +263,19 @@ await api.realtimeSubscribe(); // reads seq_id/snapshot_at_ms from the inbox q
|
|
|
262
263
|
api.realtimeDisconnect();
|
|
263
264
|
```
|
|
264
265
|
|
|
266
|
+
`realtimeConnect()` reuses the client only while it is connected; after a drop it
|
|
267
|
+
builds a fresh one, so **attach your handlers to the client it returns** and
|
|
268
|
+
re-attach whenever it differs from the last one you saw:
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
let client = await api.realtimeConnect();
|
|
272
|
+
if (client !== previous) {
|
|
273
|
+
client.on("direct", onRealtimeDirect);
|
|
274
|
+
client.on("disconnected", () => reconnectLater());
|
|
275
|
+
previous = client;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
265
279
|
`RealtimeClient` (the raw client) and `RealtimeListener` (the listen() wrapper)
|
|
266
280
|
are exported too, for callers that want to drive the socket themselves.
|
|
267
281
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/insta-chat",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
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",
|
package/src/api.js
CHANGED
|
@@ -338,7 +338,17 @@ class InstaAPI {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
async realtimeConnect(options = {}) {
|
|
341
|
-
|
|
341
|
+
// A dropped client stays on the instance until something replaces it; reuse
|
|
342
|
+
// it only while it is actually connected, otherwise the caller gets a dead
|
|
343
|
+
// client back and believes the reconnect succeeded (the watcher's flapping
|
|
344
|
+
// loop). A new RealtimeClient is cheap: the MQTT/TLS connection is made by
|
|
345
|
+
// connect().
|
|
346
|
+
if (!this._realtime || !this._realtime.connected) {
|
|
347
|
+
if (this._realtime) {
|
|
348
|
+
try { this._realtime.disconnect(); } catch (_) {}
|
|
349
|
+
}
|
|
350
|
+
this._realtime = this.realtimeClient(options);
|
|
351
|
+
}
|
|
342
352
|
await this._realtime.connect();
|
|
343
353
|
return this._realtime;
|
|
344
354
|
}
|
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) {
|
package/src/realtime/client.js
CHANGED
|
@@ -33,12 +33,17 @@ const {
|
|
|
33
33
|
writeSubscribePacket,
|
|
34
34
|
decodePacket,
|
|
35
35
|
tryDecompressPayload,
|
|
36
|
-
parseJsonPayload
|
|
36
|
+
parseJsonPayload,
|
|
37
|
+
KEEP_ALIVE_SECONDS
|
|
37
38
|
} = require("./mqttot");
|
|
38
39
|
|
|
39
40
|
const REALTIME_HOST = "edge-mqtt.facebook.com";
|
|
40
41
|
const IG_REALTIME_APP_ID = 567067343352427;
|
|
41
42
|
const REALTIME_SUBSCRIBE_TOPICS = [88, 135, 149, 150, 133, 146];
|
|
43
|
+
// The PINGREQ interval (ms). It must fire well inside the CONNECT keep-alive
|
|
44
|
+
// (KEEP_ALIVE_SECONDS), or the broker tears the socket down between pings — a
|
|
45
|
+
// 20s keep-alive with a 30s ping got the connection dropped every ~30s.
|
|
46
|
+
const PING_INTERVAL_MS = 25000;
|
|
42
47
|
|
|
43
48
|
// An Android app identity. The broker expects the app's own UA + app version in
|
|
44
49
|
// the CONNECT's appSpecificInfo; a web UA is rejected.
|
|
@@ -138,8 +143,14 @@ class RealtimeClient extends EventEmitter {
|
|
|
138
143
|
|
|
139
144
|
async connect() {
|
|
140
145
|
if (this.connected) return this;
|
|
146
|
+
// A previous connection that dropped leaves a closed transport and a live
|
|
147
|
+
// ping timer behind; clear both so the reconnect is clean and pings do not
|
|
148
|
+
// accumulate one per attempt.
|
|
149
|
+
if (this._pingTimer) clearInterval(this._pingTimer);
|
|
150
|
+
this._pingTimer = null;
|
|
151
|
+
if (this.transport && typeof this.transport.reset === "function") this.transport.reset();
|
|
141
152
|
await this.transport.connect();
|
|
142
|
-
await this.transport.send(writeConnectPacket(this.buildConnection()));
|
|
153
|
+
await this.transport.send(writeConnectPacket(this.buildConnection(), KEEP_ALIVE_SECONDS));
|
|
143
154
|
const packet = decodePacket(await this.transport.recvPacket());
|
|
144
155
|
if (packet.packetType !== "connack" || packet.returnCode !== 0) {
|
|
145
156
|
throw new Error(`Realtime MQTT connect failed (return code ${packet.returnCode})`);
|
|
@@ -153,7 +164,9 @@ class RealtimeClient extends EventEmitter {
|
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
_startPing() {
|
|
156
|
-
|
|
167
|
+
// Ping comfortably inside the keep-alive the CONNECT advertised, so the
|
|
168
|
+
// broker never times the connection out between pings.
|
|
169
|
+
const keepAliveMs = this.options.keepAliveMs || PING_INTERVAL_MS;
|
|
157
170
|
this._pingTimer = setInterval(() => {
|
|
158
171
|
if (this.connected) this.transport.send(writePingreqPacket()).catch(() => {});
|
|
159
172
|
}, keepAliveMs);
|
|
@@ -314,7 +327,12 @@ class RealtimeClient extends EventEmitter {
|
|
|
314
327
|
this.connected = false;
|
|
315
328
|
if (this._pingTimer) clearInterval(this._pingTimer);
|
|
316
329
|
this._pingTimer = null;
|
|
317
|
-
|
|
330
|
+
// send() returns a promise (the socket write); during teardown that write is
|
|
331
|
+
// cancelled and rejects with ECANCELED, so the rejection must be swallowed
|
|
332
|
+
// here rather than becoming an unhandled rejection that crashes the process.
|
|
333
|
+
try {
|
|
334
|
+
Promise.resolve(this.transport.send(writeDisconnectPacket())).catch(() => {});
|
|
335
|
+
} catch (_) {}
|
|
318
336
|
this.transport.disconnect();
|
|
319
337
|
return this;
|
|
320
338
|
}
|
|
@@ -367,6 +385,8 @@ module.exports = {
|
|
|
367
385
|
REALTIME_HOST,
|
|
368
386
|
IG_REALTIME_APP_ID,
|
|
369
387
|
REALTIME_SUBSCRIBE_TOPICS,
|
|
388
|
+
KEEP_ALIVE_SECONDS,
|
|
389
|
+
PING_INTERVAL_MS,
|
|
370
390
|
TOPICS,
|
|
371
391
|
ANDROID_UA,
|
|
372
392
|
APP_VERSION,
|
package/src/realtime/mqttot.js
CHANGED
|
@@ -366,7 +366,12 @@ function tryDecompressPayload(data) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
|
|
369
|
+
// The CONNECT keep-alive in seconds. The client's PINGREQ timer must fire well
|
|
370
|
+
// inside this window (see client.js PING_INTERVAL_MS); the broker drops a client
|
|
371
|
+
// that stays silent for ~1.5x the keep-alive.
|
|
372
|
+
const KEEP_ALIVE_SECONDS = 60;
|
|
373
|
+
|
|
374
|
+
function writeConnectPacket(connection, keepAlive = KEEP_ALIVE_SECONDS) {
|
|
370
375
|
const payload = compressPayload(connection.toThrift());
|
|
371
376
|
const header = Buffer.concat([
|
|
372
377
|
writeUtf8("MQTToT"),
|
|
@@ -495,7 +500,19 @@ class SocketMQTToTTransport {
|
|
|
495
500
|
this._error = null;
|
|
496
501
|
}
|
|
497
502
|
|
|
503
|
+
// Clears the failure/closed state so the transport can be re-connected after a
|
|
504
|
+
// broker drop. Without this, a reconnect on the same transport rejects every
|
|
505
|
+
// recvPacket() with the stale "MQTT socket closed" error and can never come
|
|
506
|
+
// back up — which is what made the realtime client flap.
|
|
507
|
+
reset() {
|
|
508
|
+
this._buffer = Buffer.alloc(0);
|
|
509
|
+
this._waiters = [];
|
|
510
|
+
this._closed = false;
|
|
511
|
+
this._error = null;
|
|
512
|
+
}
|
|
513
|
+
|
|
498
514
|
connect() {
|
|
515
|
+
this.reset();
|
|
499
516
|
return new Promise((resolve, reject) => {
|
|
500
517
|
const socket = tls.connect({
|
|
501
518
|
host: this.host,
|
|
@@ -603,6 +620,7 @@ module.exports = {
|
|
|
603
620
|
parseJsonPayload,
|
|
604
621
|
writeThriftObject,
|
|
605
622
|
writeThriftStruct,
|
|
623
|
+
KEEP_ALIVE_SECONDS,
|
|
606
624
|
readThriftObject: (data, descriptors) => new ThriftReader(data).readStruct(descriptors),
|
|
607
625
|
zigzag,
|
|
608
626
|
unzigzag
|