@eryxenx/insta-chat 2.1.2 → 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 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.1.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
- // 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);
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
- await this._realtime.connect();
353
- return this._realtime;
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) {
@@ -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 = senderID;
83
- try {
84
- const u = await this.client.api.getUserInfo(senderID);
85
- if (u) senderName = u.full_name || u.username || senderID;
86
- } catch (_) {}
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",
@@ -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).
@@ -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 messageID = String(message.item_id || match[2]);
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
- const itemType = String(message.item_type || message.content_type || "text").toLowerCase();
47
- if (/reaction|action_log|like|video_call/.test(itemType)) return null;
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 (itemType !== "text") {
62
+ if (!isText) {
52
63
  attachments = attachmentsFromContent(message.media || message.content || message);
53
64
  }
54
- if (itemType !== "text" && attachments.length === 0) {
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,
@@ -55,15 +55,32 @@ class RealtimeListener {
55
55
 
56
56
  async start(callback) {
57
57
  this._callback = callback;
58
- this._client = new RealtimeClient(this.context, this.options);
59
- this._client.on("message", (event) => this._handleMessageSync(event));
60
- this._client.on("error", (err) => this._emitError(err));
61
- this._client.on("disconnected", () => {
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) this._client.disconnect();
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 };
@@ -243,9 +243,12 @@ class SendPacer extends EventEmitter {
243
243
  const sinceGlobal = now - this.lastSendAt;
244
244
  const sinceThread = now - (this.perThreadLastSendAt.get(key) || 0);
245
245
  const base = Math.max(gap - sinceGlobal, this.perThreadMinGapMs - sinceThread, 0);
246
- const jitter = this.jitterMs > 0 ? this.jitterMs * (0.3 + Math.random() * 0.7) : 0;
246
+ // Jitter only breaks up back-to-back sends. When nothing is queued (the first
247
+ // reply to a message after a quiet spell) base is 0 and the send goes out
248
+ // immediately, instead of always paying 0.3-1.0x jitterMs.
249
+ const jitter = base > 0 && this.jitterMs > 0 ? this.jitterMs * (0.3 + Math.random() * 0.7) : 0;
247
250
 
248
- await this._sleep(base + jitter);
251
+ if (base + jitter > 0) await this._sleep(base + jitter);
249
252
 
250
253
  const stamp = Date.now();
251
254
  this.lastSendAt = stamp;