@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 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. See [Listening](#listening).
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.0",
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
- if (!this._realtime) this._realtime = this.realtimeClient(options);
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
  }
@@ -78,20 +78,34 @@ class MediaMethods {
78
78
  }
79
79
 
80
80
  async _commitMedia(threadID, attachmentFbid, options = {}) {
81
- return this.http.postGraphQL(
82
- "IGDirectMediaSendMutation",
83
- DOC_ID.MEDIA_SEND,
84
- {
85
- attachment_fbid: String(attachmentFbid),
86
- thread_id: String(threadID),
87
- offline_threading_id: this._offlineThreadingId(),
88
- reply_to_message_id: options.replyToMessageId || null,
89
- forwarded_from_thread_id: null,
90
- is_forwarded_from_own_message: null
91
- },
92
- this._threadReferer(threadID),
93
- { throwOnError: true }
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) {
@@ -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
- const keepAliveMs = this.options.keepAliveMs || 30000;
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
- try { this.transport.send(writeDisconnectPacket()); } catch (_) {}
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,
@@ -366,7 +366,12 @@ function tryDecompressPayload(data) {
366
366
  }
367
367
  }
368
368
 
369
- function writeConnectPacket(connection, keepAlive = 20) {
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