@eryxenx/insta-chat 2.0.3 → 2.0.5

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,36 @@ 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.5] - 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - **Empty text send logged the account out.** `MessageMethods.send()` now drops
12
+ an empty (or whitespace-only) body instead of firing an
13
+ `IGDirectTextSendMutation` with an empty `sensitive_string_value`. Instagram
14
+ answers that with a 302 to the login page, which the HTTP layer classifies as
15
+ `IG_SESSION_INVALID`, so a command that built an empty reply (for example
16
+ `adduser` handed a username it could not resolve) made the poller stop and
17
+ report the session as dead. The dropped send returns
18
+ `{ messageID: null, dropped: "empty" }`.
19
+
20
+ ## [2.0.4] - 2026-10-10
21
+
22
+ ### Fixed
23
+
24
+ - **Captioned media lagged behind its text.** A message carrying both text and
25
+ an attachment uploads the media first, then commits the caption and the media
26
+ back-to-back, so the text and the image land together instead of the image
27
+ trailing by the whole upload time. Instagram's DM media mutation has no caption
28
+ field, so caption + attachment is still two messages; this only removes the
29
+ perceived gap after the caption.
30
+
31
+ ### Added
32
+
33
+ - `antiSuspension.waitForTurn(threadID, { immediate })`: an immediate turn that
34
+ still counts against the volume caps but skips the inter-message gap, so a
35
+ captioned attachment can follow its caption at once.
36
+
7
37
  ## [1.0.12] - 2026-10-10
8
38
 
9
39
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eryxenx/insta-chat",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
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",
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",
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",
27
27
  "build": "node scripts/build.js"
28
28
  },
29
29
  "keywords": [
@@ -95,22 +95,28 @@ class MediaMethods {
95
95
  }
96
96
 
97
97
  async _send(threadID, uploadOptions, options) {
98
- // The attached text is sent as its own message *before* the media, so the
99
- // recipient sees the caption on top and the photo/attachment underneath.
100
- // (GoatBot-Pro commands format a message as a header line followed by an
101
- // image — sending the media first renders the image on top, which is not
102
- // what the commands intend.) Media sends count against the same
103
- // pacing/volume budget as text, so both are paced.
104
- if (options.text) {
105
- await globalAntiSuspension.waitForTurn(threadID);
106
- await this._sendCaption(threadID, options.text);
107
- }
108
- await globalAntiSuspension.waitForTurn(threadID);
98
+ // Instagram's DM media mutation has no caption field, so an attachment with
99
+ // text is necessarily two messages. The recipient-visible contract is that
100
+ // the caption reaches Instagram before the media (so the text renders on top
101
+ // and the image underneath — what GoatBot-Pro commands intend).
102
+ //
103
+ // The upload is by far the slow part of a media send. If the caption is
104
+ // committed while the upload is still in flight, the text appears at once
105
+ // and the image arrives a couple of seconds later on its own. Doing the
106
+ // upload first and then committing caption + media back-to-back makes the
107
+ // two land together, which is what a person perceives as "one message".
109
108
  const metadata = await this._upload(threadID, uploadOptions);
110
109
  const fbid = metadata.fbid || metadata.video_id || metadata.audio_id;
111
110
  if (!fbid) throw new Error(`Upload returned no media id: ${JSON.stringify(metadata)}`);
112
111
  let result;
113
112
  try {
113
+ if (options.text) {
114
+ await globalAntiSuspension.waitForTurn(threadID);
115
+ await this._sendCaption(threadID, options.text);
116
+ }
117
+ // Count the media against the same pacing/volume budget, but with no gap
118
+ // after a caption so the image immediately follows the text.
119
+ await globalAntiSuspension.waitForTurn(threadID, { immediate: !!options.text });
114
120
  result = await this._commitMedia(threadID, fbid, options);
115
121
  } catch (error) {
116
122
  globalAntiSuspension.noteSendError(error);
@@ -27,6 +27,15 @@ class MessageMethods {
27
27
  // (doc_id 26911679871773184). Passing it anywhere else sets a pointer without
28
28
  // rendering the quote bubble.
29
29
  async send(text, threadID, replyToItemId = null) {
30
+ // Instagram rejects an empty text send (IGDirectTextSendMutation with an
31
+ // empty `sensitive_string_value`) with a 302 to the login page. The HTTP
32
+ // layer classifies any 3xx as an auth failure, so emitting one was enough to
33
+ // make the poller declare the session dead. Commands that build a reply from
34
+ // resolvable input (e.g. adduser given an unresolvable username) can end up
35
+ // with an empty string; drop it rather than send it.
36
+ if (text === undefined || text === null || String(text).trim() === "") {
37
+ return { messageID: null, threadID: String(threadID), dropped: "empty" };
38
+ }
30
39
  // Pace outbound traffic so a personal account does not look scripted.
31
40
  await globalAntiSuspension.waitForTurn(threadID);
32
41
  const replyId =
@@ -204,19 +204,34 @@ class SendPacer extends EventEmitter {
204
204
  return true;
205
205
  }
206
206
 
207
- waitForTurn(threadID) {
207
+ waitForTurn(threadID, opts) {
208
208
  if (!this.enabled) return Promise.resolve();
209
- const run = this._queue.then(() => this._turn(threadID));
209
+ const run = this._queue.then(() => this._turn(threadID, opts));
210
210
  this._queue = run.catch(() => {});
211
211
  return run.catch(() => {});
212
212
  }
213
213
 
214
- async _turn(threadID) {
214
+ async _turn(threadID, opts = {}) {
215
215
  this._rollWindows();
216
216
  this._decayStrikes();
217
217
  this._prune();
218
218
 
219
219
  const key = String(threadID);
220
+
221
+ // A captioned media is one logical message. Instagram's own client sends the
222
+ // caption text and the attachment back-to-back, not seconds apart, so the
223
+ // media pass asks for an immediate turn: still counted against the volume
224
+ // caps (so the hourly/daily budget stays accurate) but without the
225
+ // inter-message gap that made the image trail the text.
226
+ if (opts && opts.immediate) {
227
+ const stamp = Date.now();
228
+ this.lastSendAt = stamp;
229
+ this.perThreadLastSendAt.set(key, stamp);
230
+ this.hourSendCount++;
231
+ this.daySendCount++;
232
+ return;
233
+ }
234
+
220
235
  const cooling = this.isCoolingDown();
221
236
  const overCap = this.hourSendCount >= this._hourlyCap() || this.daySendCount >= this.dailyCap;
222
237