@eryxenx/insta-chat 2.0.3 → 2.0.4

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,23 @@ 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.4] - 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - **Captioned media lagged behind its text.** A message carrying both text and
12
+ an attachment uploads the media first, then commits the caption and the media
13
+ back-to-back, so the text and the image land together instead of the image
14
+ trailing by the whole upload time. Instagram's DM media mutation has no caption
15
+ field, so caption + attachment is still two messages; this only removes the
16
+ perceived gap after the caption.
17
+
18
+ ### Added
19
+
20
+ - `antiSuspension.waitForTurn(threadID, { immediate })`: an immediate turn that
21
+ still counts against the volume caps but skips the inter-message gap, so a
22
+ captioned attachment can follow its caption at once.
23
+
7
24
  ## [1.0.12] - 2026-10-10
8
25
 
9
26
  ### 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.4",
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",
@@ -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);
@@ -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