@eryxenx/fca 1.1.1 → 1.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/README.md CHANGED
@@ -420,109 +420,6 @@ api.uploadImageToImgbb(imageUrl);
420
420
 
421
421
  ---
422
422
 
423
- ## 🛡️ Command Anti-Spam (Humanizer)
424
-
425
- Nobody — not even the bot owner — can spam the bot with commands. This makes
426
- the account behave like a human operator, which reduces the chance of
427
- Facebook flagging it as a script and logging it out / suspending it.
428
-
429
- On by default. It works on **every incoming command** (and every incoming
430
- message that starts with a command prefix):
431
-
432
- - **Human reaction delay** — an accepted command is only delivered to your
433
- listener after a short random delay (default `2–3s`), so the bot replies
434
- like a human typing, never instantly like a script.
435
- - **Spam window** — after a command is accepted in a conversation, any further
436
- commands in that conversation within `cooldownMs` (default `5.5s`) are
437
- **silently ignored**. `help help help help` → exactly **one** reply.
438
- - **Idle reset** — after `resetMs` (default `10s`) with no commands in a
439
- conversation, the state resets and the next command starts fresh.
440
- - **Global cap** — at most `maxPerMinute` commands are accepted per minute
441
- across all conversations (default `20`).
442
-
443
- Non-command messages always pass through instantly, untouched.
444
-
445
- ### How to use
446
-
447
- It's already active after `login()`. No code needed:
448
-
449
- ```javascript
450
- const login = require("@eryxenx/fca");
451
-
452
- login({ appState }, {}, (err, api) => {
453
- if (err) throw err;
454
-
455
- api.listenMqtt((err, event) => {
456
- if (err) throw err;
457
- // Commands arrive here already delayed by 2-3s, and spam bursts are gone.
458
- if (event.body === "/help") api.sendMessage("help text", event.threadID);
459
- });
460
- });
461
- ```
462
-
463
- ### Configuration
464
-
465
- Pass a `humanize` object to `login()`, or call `api.setOptions({ humanize })`
466
- at runtime:
467
-
468
- | Option | Type | Default | Description |
469
- |--------|------|---------|-------------|
470
- | `enabled` | boolean | `true` | Master switch |
471
- | `reactDelayMs` | `[min, max]` | `[2000, 3000]` | Random delay before an accepted command is delivered |
472
- | `cooldownMs` | number | `5500` | Spam window per conversation (seconds between commands) |
473
- | `resetMs` | number | `10000` | Idle time after which a conversation resets |
474
- | `maxPerMinute` | number | `20` | Global command cap per minute (0 = unlimited) |
475
- | `commands` | string[] | `["/", "!", "."]` | Prefixes that mark a message as a command |
476
- | `perThread` | boolean | `true` | Cooldown per conversation (false = global) |
477
-
478
- ```javascript
479
- const login = require("@eryxenx/fca");
480
-
481
- login({ appState }, {
482
- humanize: {
483
- reactDelayMs: [2500, 4000],
484
- cooldownMs: 6000,
485
- resetMs: 12000,
486
- maxPerMinute: 15,
487
- commands: ["/"]
488
- }
489
- }, (err, api) => { /* ... */ });
490
- ```
491
-
492
- To turn it off completely:
493
-
494
- ```javascript
495
- // option A: at login
496
- login({ appState }, { humanize: { enabled: false } }, cb);
497
- // option B: at runtime
498
- api.setOptions({ humanize: { enabled: false } });
499
- ```
500
-
501
- ### Programmatic access
502
-
503
- The humanizer is a normal class, also exported from the package:
504
-
505
- ```javascript
506
- const { CommandHumanizer, createCommandHumanizer } = require("@eryxenx/fca");
507
-
508
- const h = new CommandHumanizer({ reactDelayMs: [2000, 3000] });
509
- const d = h.shouldProcess({ body: "/help", threadID: "t1" });
510
- // d.ok === true, d.delayMs in [2000, 3000]
511
- ```
512
-
513
- At runtime, `api.getHumanizer()` returns the active instance so you can
514
- inspect statistics or reset a conversation:
515
-
516
- ```javascript
517
- const h = api.getHumanizer();
518
- console.log(h.stats()); // accepted / dropped counts
519
- h.reset("381234567890"); // clear the state for one thread
520
- ```
521
-
522
- > Note: this operates on **incoming** command volume. Outgoing send pacing
523
- > (the `globalAntiSuspension` pacer in `src/utils/antiSuspension.js`) is a
524
- > separate, complementary mechanism that spaces out actual `sendMessage` calls.
525
-
526
423
  ## 📋 Login Options
527
424
 
528
425
  | Option | Type | Default | Description |
@@ -538,7 +435,6 @@ h.reset("381234567890"); // clear the state for one thread
538
435
  | `emitReady` | boolean | false | Emit ready event when MQTT connected |
539
436
  | `proxy` | string | — | HTTP proxy URL |
540
437
  | `userAgent` | string | Safari UA | Override HTTP User-Agent |
541
- | `humanize` | object | enabled | Command anti-spam config (see above) |
542
438
 
543
439
  ---
544
440
 
package/module/login.js CHANGED
@@ -101,9 +101,6 @@ function login(loginData, options, callback) {
101
101
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
102
102
  humanize: undefined
103
103
  };
104
- if (options && options.humanize !== undefined) {
105
- globalOptions.humanize = options.humanize;
106
- }
107
104
  setOptions(globalOptions, options);
108
105
  let prCallback = null;
109
106
  let rejectFunc = null;
@@ -1341,8 +1341,7 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
1341
1341
  // sendMessage override with nexca version (better MQTT + HTTP fallback)
1342
1342
  try {
1343
1343
  const nexcaSendMsg = require("../src/api/socket/sendMessage")(defaultFuncs, api, ctxMain);
1344
- const { wrapSendMessage } = require("../src/utils/outboundRateLimit");
1345
- api.sendMessage = wrapSendMessage(nexcaSendMsg, ctxMain, config);
1344
+ api.sendMessage = nexcaSendMsg;
1346
1345
  api.sendMessageMqtt = require("../src/api/socket/sendMessageMqtt")(defaultFuncs, api, ctxMain);
1347
1346
  api.OldMessage = require("../src/api/socket/OldMessage")(defaultFuncs, api, ctxMain);
1348
1347
  api.sendMessageDM = (msg, threadID, cb, replyTo) => api.OldMessage(msg, threadID, cb, replyTo, true);
package/module/options.js CHANGED
@@ -24,6 +24,10 @@ function setOptions(globalOptions, options) {
24
24
  globalOptions.userAgent = options.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
25
25
  break;
26
26
  }
27
+ case "humanize": {
28
+ globalOptions.humanize = options.humanize;
29
+ break;
30
+ }
27
31
  case "proxy": {
28
32
  if (typeof options.proxy !== "string") {
29
33
  delete globalOptions.proxy;
@@ -34,10 +38,6 @@ function setOptions(globalOptions, options) {
34
38
  }
35
39
  break;
36
40
  }
37
- case "humanize": {
38
- globalOptions.humanize = options.humanize;
39
- break;
40
- }
41
41
  default: {
42
42
  logger("setOptions Unrecognized option given to setOptions: " + key, "warn");
43
43
  break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eryxenx/fca",
3
- "version": "1.1.1",
4
- "description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — with Command Anti-Spam (Humanizer)",
3
+ "version": "1.1.2",
4
+ "description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — send messages, media, reactions & more in encrypted chats, hassle-free",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "exports": {
@@ -2,7 +2,6 @@
2
2
 
3
3
  var logger = require("../../utils/nexca-logger");
4
4
  var EventEmitter = require("events");
5
- var { createHumanizerMiddleware } = require("../../utils/humanizer");
6
5
 
7
6
  /**
8
7
  * listenE2EE — merges E2EE messages into the same event stream as MQTT.
@@ -66,19 +65,13 @@ module.exports = function (defaultFuncs, api, ctx) {
66
65
 
67
66
  event.isE2EE = true;
68
67
 
69
- // Run E2EE events through the same middleware pipeline as
70
- // MQTT events so command anti-spam also applies here.
71
68
  if (ctx._middleware && ctx._middleware.count > 0) {
72
- // Ensure the humanizer middleware is present for E2EE too.
73
- if (ctx._humanizer && !ctx._humanizerMiddlewareInstalled) {
74
- ctx._middleware.use("humanizer", createHumanizerMiddleware(ctx._humanizer, logger));
75
- ctx._humanizerMiddlewareInstalled = true;
76
- }
77
- return ctx._middleware.process(event, (err2, processed) => {
78
- if (err2) { logger.error("listenE2EE", "Middleware error: " + (err2 && err2.message ? err2.message : String(err2))); return; }
79
- if (!processed) return; // dropped by middleware
80
- globalCallback(null, processed);
69
+ ctx._middleware.process(event, function (middlewareErr, processedEvent) {
70
+ if (middlewareErr) return globalCallback(middlewareErr, null);
71
+ if (processedEvent === null) return;
72
+ globalCallback(null, processedEvent);
81
73
  });
74
+ return;
82
75
  }
83
76
 
84
77
  globalCallback(null, event);
@@ -51,17 +51,23 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
51
51
  }
52
52
  const middleware = ctx._middleware;
53
53
 
54
- function installHumanizerMiddleware() {
55
- if (ctx._humanizerMiddlewareInstalled) return;
56
- ctx._humanizerMiddlewareInstalled = true;
57
-
58
- if (!ctx._humanizer) {
59
- ctx._humanizer = createCommandHumanizer(ctx.globalOptions.humanize);
54
+ if (!ctx._humanizer) {
55
+ ctx._humanizer = createCommandHumanizer(ctx.globalOptions.humanize);
56
+ }
57
+ if (!ctx._humanizerMiddlewareInstalled) {
58
+ const humanizeEnabled = ctx.globalOptions.humanize === undefined ||
59
+ (ctx.globalOptions.humanize && ctx.globalOptions.humanize.enabled !== false);
60
+ if (humanizeEnabled) {
61
+ try {
62
+ middleware.use("humanizer", createHumanizerMiddleware(ctx._humanizer, logger));
63
+ ctx._humanizerMiddlewareInstalled = true;
64
+ logger("Humanizer middleware installed (command anti-spam active)", "info");
65
+ } catch (e) {
66
+ logger(`Humanizer middleware install failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
67
+ }
60
68
  }
61
-
62
- middleware.use("humanizer", createHumanizerMiddleware(ctx._humanizer, logger));
63
- logger("Humanizer middleware installed (command anti-spam active)", "info");
64
69
  }
70
+ api.getHumanizer = () => ctx._humanizer || null;
65
71
 
66
72
  function installPostGuard() {
67
73
  if (ctx._postGuarded) return defaultFuncs.post;
@@ -362,25 +368,6 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
362
368
 
363
369
  const msgEmitter = new MessageEmitter();
364
370
 
365
- // Command anti-spam. Enabled by default so that nobody (the owner or
366
- // anyone else) can spam the bot with commands — which also protects the
367
- // account from looking automated to Facebook. Opt out via
368
- // api.setOptions({ humanize: { enabled: false } }) or pass
369
- // { humanize: { enabled: false } } to login().
370
- const humanizeEnabled = ctx.globalOptions.humanize === undefined ||
371
- (ctx.globalOptions.humanize && ctx.globalOptions.humanize.enabled !== false);
372
-
373
- if (humanizeEnabled) {
374
- try {
375
- installHumanizerMiddleware();
376
- } catch (e) {
377
- logger(`Humanizer middleware install failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
378
- }
379
- }
380
-
381
- // Expose the humanizer for diagnostics / runtime reconfiguration.
382
- api.getHumanizer = () => ctx._humanizer || null;
383
-
384
371
  // Original callback without middleware
385
372
  const originalCallback = callback || function (error, message) {
386
373
  if (error) { logger("mqtt emit error", "error"); return msgEmitter.emit("error", error); }
@@ -1,27 +1,5 @@
1
1
  "use strict";
2
2
 
3
- /**
4
- * CommandHumanizer — makes a bot behave like a human when it receives
5
- * commands so that neither the owner nor anyone else can spam the bot.
6
- * This keeps the account from looking like a script to Facebook.
7
- *
8
- * Behavior (all tunable via options):
9
- * 1. Human reaction delay — an accepted command is only forwarded to the
10
- * bot handler after a short random delay (default 2-3 seconds), so the
11
- * reply never fires instantly like a script.
12
- * 2. Spam window — once a command in a conversation is accepted, any
13
- * further command in that same conversation within `cooldownMs`
14
- * (default 5.5s) is IGNORED. A burst such as "help help help help"
15
- * therefore gets exactly one reply.
16
- * 3. Idle reset — after `resetMs` (default 10s) with no commands in a
17
- * conversation, the state resets and the next command is treated as a
18
- * fresh one.
19
- * 4. Global cap — at most `maxPerMinute` commands are accepted per minute
20
- * across all conversations; anything past the cap is ignored.
21
- *
22
- * Non-command messages (no command prefix) always pass through untouched.
23
- */
24
-
25
3
  const DEFAULT_OPTIONS = Object.freeze({
26
4
  enabled: true,
27
5
  reactDelayMs: [2000, 3000],
@@ -39,6 +17,28 @@ function isCommandLike(body, prefixes) {
39
17
  return prefixes.some(p => typeof p === "string" && p.length > 0 && text.startsWith(p));
40
18
  }
41
19
 
20
+ function isNoPrefixAdminCommand(event) {
21
+ try {
22
+ const GoatBot = typeof global !== "undefined" ? global.GoatBot : undefined;
23
+ if (!GoatBot || !GoatBot.config || !GoatBot.commands) return false;
24
+
25
+ const cfg = GoatBot.config;
26
+ if (!cfg.noPrefix || cfg.noPrefix.enable !== true) return false;
27
+ if (!Array.isArray(cfg.adminBot) || !cfg.adminBot.includes(event.senderID)) return false;
28
+
29
+ const body = typeof event.body === "string" ? event.body.trim() : "";
30
+ if (!body) return false;
31
+
32
+ const possibleCmd = body.split(/ +/)[0].toLowerCase();
33
+ if (GoatBot.commands.has(possibleCmd)) return true;
34
+
35
+ const alias = GoatBot.aliases && GoatBot.aliases.get(possibleCmd);
36
+ return Boolean(alias && GoatBot.commands.has(alias));
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
42
  function randomBetween(min, max) {
43
43
  if (Array.isArray(min)) {
44
44
  const pair = min;
@@ -54,7 +54,7 @@ function randomBetween(min, max) {
54
54
  class CommandHumanizer {
55
55
  constructor(options) {
56
56
  this.setOptions(options);
57
- this._threads = new Map(); // key -> state
57
+ this._threads = new Map();
58
58
  this._global = { windowStart: 0, count: 0, accepted: 0, dropped: 0 };
59
59
  this._eventCount = 0;
60
60
  }
@@ -68,10 +68,28 @@ class CommandHumanizer {
68
68
  this.maxPerMinute = typeof o.maxPerMinute === "number" ? o.maxPerMinute : DEFAULT_OPTIONS.maxPerMinute;
69
69
  this.prefixes = o.commands || o.prefixes || DEFAULT_OPTIONS.commands;
70
70
  this.perThread = o.perThread !== false;
71
+ if (o.matcher !== undefined) {
72
+ this.matcher = typeof o.matcher === "function" ? o.matcher : null;
73
+ } else if (this.matcher === undefined) {
74
+ this.matcher = null;
75
+ }
76
+ }
77
+
78
+ setMatcher(fn) {
79
+ this.matcher = typeof fn === "function" ? fn : null;
71
80
  }
72
81
 
73
82
  isCommand(event) {
74
- return Boolean(event && isCommandLike(event.body, this.prefixes));
83
+ if (!event) return false;
84
+ if (isCommandLike(event.body, this.prefixes)) return true;
85
+ if (typeof this.matcher === "function") {
86
+ try {
87
+ if (this.matcher(event)) return true;
88
+ } catch {
89
+ // ignore matcher errors, fall through to built-in detection
90
+ }
91
+ }
92
+ return isNoPrefixAdminCommand(event);
75
93
  }
76
94
 
77
95
  _keyFor(event) {
@@ -89,12 +107,6 @@ class CommandHumanizer {
89
107
  return true;
90
108
  }
91
109
 
92
- /**
93
- * Decide what to do with a command event, based purely on arrival time
94
- * (the emission delay is applied separately by the middleware).
95
- *
96
- * @returns {{ok: boolean, delayMs: number, reason?: string}}
97
- */
98
110
  shouldProcess(event) {
99
111
  this._eventCount++;
100
112
  if (!this.enabled) return { ok: true, delayMs: 0 };
@@ -108,8 +120,6 @@ class CommandHumanizer {
108
120
  this._threads.set(key, st);
109
121
  }
110
122
 
111
- // Idle reset: no command in this conversation for resetMs -> the next
112
- // command starts a fresh window instead of inheriting the old cooldown.
113
123
  if (st.lastCmdAt > 0 && (now - st.lastCmdAt >= this.resetMs)) {
114
124
  st.acceptedAt = 0;
115
125
  st.coolUntil = 0;
@@ -156,12 +166,6 @@ function createCommandHumanizer(options) {
156
166
  return new CommandHumanizer(options);
157
167
  }
158
168
 
159
- /**
160
- * Middleware for the FCA middleware system (api.useMiddleware).
161
- * - Non-commands: forwarded immediately.
162
- * - Dropped commands: next(false) so the bot never sees them.
163
- * - Accepted commands: forwarded after the human reaction delay.
164
- */
165
169
  function createHumanizerMiddleware(humanizer, logger) {
166
170
  const log = typeof logger === "function"
167
171
  ? (msg, level) => logger(msg, level || "info")
@@ -175,16 +179,13 @@ function createHumanizerMiddleware(humanizer, logger) {
175
179
  if (!decision.ok) {
176
180
  const snippet = typeof event.body === "string" ? event.body.slice(0, 40) : String(event.body || "");
177
181
  log(`Humanizer: dropped command "${snippet}" (${decision.reason})`, "warn");
178
- next(false); // stop processing, do not emit
182
+ next(false);
179
183
  return;
180
184
  }
181
185
 
182
186
  const delay = Math.max(0, decision.delayMs || 0);
183
187
  if (delay <= 0) { next(); return; }
184
188
 
185
- // Forward the event after a human-like delay. setTimeout is used
186
- // (instead of an async function) because this middleware system calls
187
- // next() itself; returning a promise would make it call next() twice.
188
189
  setTimeout(() => next(), delay);
189
190
  };
190
191
  }
@@ -195,5 +196,6 @@ module.exports = {
195
196
  createHumanizerMiddleware,
196
197
  randomBetween,
197
198
  isCommandLike,
199
+ isNoPrefixAdminCommand,
198
200
  DEFAULT_OPTIONS
199
- };
201
+ };
@@ -1,284 +0,0 @@
1
- "use strict";
2
-
3
- const logger = require("./nexca-logger");
4
-
5
- /**
6
- * Global outbound rate-limit / anti-spam layer for api.sendMessage().
7
- *
8
- * This module has nothing to do with hiding bot traffic from Facebook
9
- * (see antiSuspension.js for that, unrelated / untouched by this file).
10
- * Its only job is to stop a single thread/user from being able to make a
11
- * bot fire an unbounded burst of outgoing replies, and to stop many busy
12
- * threads at once from overwhelming the FCA send path.
13
- *
14
- * Design:
15
- * - Per-thread sliding window: at most `maxPerWindow` sends per thread
16
- * every `windowMs`. Extra sends for that thread are queued, not sent
17
- * immediately.
18
- * - Global concurrency cap: at most `maxConcurrentSends` sendMessage
19
- * calls in flight at once, across all threads.
20
- * - Bounded queue: at most `maxQueueSize` messages waiting at once. Once
21
- * full, new sends are rejected immediately (never silently grows
22
- * without bound).
23
- * - Queue TTL: a queued message that has been waiting longer than
24
- * `maxQueueWaitMs` is dropped instead of being sent very late.
25
- * - Optional short-window duplicate suppression (off by default).
26
- *
27
- * This wraps the *existing* sendMessage function — it never re-implements
28
- * sending, never retries a failed send, and never changes what happens on
29
- * success/failure. It only decides *when* the real sendMessage gets
30
- * called, and calls it at most once per accepted message.
31
- */
32
-
33
- const DEFAULTS = {
34
- enabled: true,
35
- windowMs: 10000, // RATE_LIMIT_WINDOW
36
- maxPerWindow: 3, // MAX_MESSAGES_PER_WINDOW (per thread/user)
37
- maxQueueSize: 500, // MAX_QUEUE_SIZE (global)
38
- maxConcurrentSends: 5, // MAX_CONCURRENT_SENDS (global)
39
- maxQueueWaitMs: 20000, // drop a queued message after this long
40
- queueTickMs: 300, // how often to re-check a blocked queue
41
- dedupe: {
42
- enabled: false, // opt-in: suppress identical back-to-back text
43
- windowMs: 1500
44
- }
45
- };
46
-
47
- function buildConfig(ctx, projectConfig) {
48
- const fromFile = (projectConfig && projectConfig.rateLimiter) || {};
49
- const fromLoginOpts = (ctx && ctx.globalOptions && ctx.globalOptions.rateLimiter) || {};
50
-
51
- const merged = Object.assign({}, DEFAULTS, fromFile, fromLoginOpts);
52
- merged.dedupe = Object.assign({}, DEFAULTS.dedupe, fromFile.dedupe, fromLoginOpts.dedupe);
53
-
54
- // Same opt-out convention already used for antiBan in sendMessage.js:
55
- // globalOptions.outboundRateLimit === false disables this layer entirely.
56
- if (ctx && ctx.globalOptions && ctx.globalOptions.outboundRateLimit === false) {
57
- merged.enabled = false;
58
- }
59
- return merged;
60
- }
61
-
62
- function normalizeDedupeBody(msg) {
63
- // Only dedupe plain text sends. Attachments/stickers/locations etc. are
64
- // left alone since "identical" is much less meaningful for them and the
65
- // risk of dropping something the bot actually meant to (re)send is higher.
66
- if (typeof msg === "string") return msg;
67
- if (msg && typeof msg === "object" && !msg.attachment && typeof msg.body === "string") {
68
- return msg.body;
69
- }
70
- return null;
71
- }
72
-
73
- class OutboundRateLimiter {
74
- constructor(originalSendMessage, config) {
75
- this.originalSendMessage = originalSendMessage;
76
- this.config = config;
77
-
78
- this.perThread = new Map(); // threadKey -> [timestamps]
79
- this.dedupeCache = new Map(); // threadKey -> { body, at }
80
- this.queue = []; // [{ msg, threadID, replyToMessage, isSingleUser, key, settle, enqueuedAt }]
81
- this.activeSends = 0;
82
- this._processing = false;
83
- this._retryTimer = null;
84
-
85
- this._gcTimer = setInterval(() => this._gc(), 60000);
86
- if (this._gcTimer.unref) this._gcTimer.unref();
87
- }
88
-
89
- destroy() {
90
- if (this._gcTimer) clearInterval(this._gcTimer);
91
- if (this._retryTimer) clearTimeout(this._retryTimer);
92
- }
93
-
94
- getStats() {
95
- return {
96
- queued: this.queue.length,
97
- activeSends: this.activeSends,
98
- trackedThreads: this.perThread.size
99
- };
100
- }
101
-
102
- _gc() {
103
- const now = Date.now();
104
- for (const [key, timestamps] of this.perThread) {
105
- while (timestamps.length && timestamps[0] <= now - this.config.windowMs) timestamps.shift();
106
- if (!timestamps.length) this.perThread.delete(key);
107
- }
108
- for (const [key, entry] of this.dedupeCache) {
109
- if (now - entry.at > this.config.dedupe.windowMs) this.dedupeCache.delete(key);
110
- }
111
- }
112
-
113
- _isRateLimited(key) {
114
- const timestamps = this.perThread.get(key);
115
- if (!timestamps || !timestamps.length) return false;
116
- const cutoff = Date.now() - this.config.windowMs;
117
- while (timestamps.length && timestamps[0] <= cutoff) timestamps.shift();
118
- return timestamps.length >= this.config.maxPerWindow;
119
- }
120
-
121
- _recordSend(key) {
122
- let timestamps = this.perThread.get(key);
123
- if (!timestamps) {
124
- timestamps = [];
125
- this.perThread.set(key, timestamps);
126
- }
127
- timestamps.push(Date.now());
128
- }
129
-
130
- _isDuplicate(key, msg) {
131
- if (!this.config.dedupe.enabled) return false;
132
- const body = normalizeDedupeBody(msg);
133
- if (body === null) return false;
134
- const last = this.dedupeCache.get(key);
135
- const now = Date.now();
136
- if (last && last.body === body && (now - last.at) < this.config.dedupe.windowMs) {
137
- return true;
138
- }
139
- this.dedupeCache.set(key, { body, at: now });
140
- return false;
141
- }
142
-
143
- _sweepExpired() {
144
- const now = Date.now();
145
- for (let i = this.queue.length - 1; i >= 0; i--) {
146
- const job = this.queue[i];
147
- if (now - job.enqueuedAt > this.config.maxQueueWaitMs) {
148
- this.queue.splice(i, 1);
149
- job.settle({ error: "sendMessage: queued message expired before it could be sent (rate-limit backlog)" });
150
- }
151
- }
152
- }
153
-
154
- _processQueue() {
155
- if (this._processing) return;
156
- this._processing = true;
157
- try {
158
- this._sweepExpired();
159
- let i = 0;
160
- while (i < this.queue.length && this.activeSends < this.config.maxConcurrentSends) {
161
- const job = this.queue[i];
162
- if (this._isRateLimited(job.key)) {
163
- i++;
164
- continue;
165
- }
166
- this.queue.splice(i, 1);
167
- this._dispatch(job);
168
- }
169
- } finally {
170
- this._processing = false;
171
- }
172
-
173
- if (this._retryTimer) return;
174
- if (!this.queue.length) return;
175
- this._retryTimer = setTimeout(() => {
176
- this._retryTimer = null;
177
- this._processQueue();
178
- }, this.config.queueTickMs);
179
- if (this._retryTimer.unref) this._retryTimer.unref();
180
- }
181
-
182
- _callOriginal(job) {
183
- const original = this.originalSendMessage;
184
- return new Promise((resolve, reject) => {
185
- try {
186
- original(job.msg, job.threadID, (err, result) => {
187
- if (err) reject(err); else resolve(result);
188
- }, job.replyToMessage, job.isSingleUser);
189
- } catch (syncErr) {
190
- reject(syncErr);
191
- }
192
- });
193
- }
194
-
195
- async _dispatch(job) {
196
- this.activeSends++;
197
- this._recordSend(job.key);
198
- try {
199
- const result = await this._callOriginal(job);
200
- job.settle(null, result);
201
- } catch (err) {
202
- // No automatic retry here on purpose — a failed send stays failed,
203
- // exactly like calling the un-wrapped sendMessage would behave.
204
- job.settle(err);
205
- } finally {
206
- this.activeSends--;
207
- this._processQueue();
208
- }
209
- }
210
-
211
- send(msg, threadID, callback, replyToMessage, isSingleUser) {
212
- if (typeof callback !== "function") callback = null;
213
-
214
- let resolveOuter, rejectOuter;
215
- const outerPromise = new Promise((res, rej) => {
216
- resolveOuter = res;
217
- rejectOuter = rej;
218
- });
219
-
220
- let settled = false;
221
- const settle = (err, result) => {
222
- if (settled) return;
223
- settled = true;
224
- if (callback) {
225
- try {
226
- callback(err || null, result);
227
- } catch (cbErr) {
228
- logger.warn("RateLimiter", "sendMessage callback threw: " + (cbErr && cbErr.message ? cbErr.message : cbErr));
229
- }
230
- }
231
- if (err) rejectOuter(err); else resolveOuter(result);
232
- };
233
-
234
- const key = threadID === undefined || threadID === null ? "unknown" : String(threadID);
235
-
236
- if (this._isDuplicate(key, msg)) {
237
- settle(null, { skipped: true, reason: "duplicate" });
238
- return outerPromise;
239
- }
240
-
241
- if (this.queue.length >= this.config.maxQueueSize) {
242
- settle({ error: "sendMessage: outbound queue is full, message dropped" });
243
- return outerPromise;
244
- }
245
-
246
- this.queue.push({
247
- msg,
248
- threadID,
249
- replyToMessage,
250
- isSingleUser,
251
- key,
252
- settle,
253
- enqueuedAt: Date.now()
254
- });
255
- this._processQueue();
256
-
257
- return outerPromise;
258
- }
259
- }
260
-
261
- /**
262
- * Wraps an existing sendMessage(msg, threadID, callback, replyToMessage,
263
- * isSingleUser) function with the rate-limit/queue layer above. Same
264
- * signature in, same signature out — callers (GoatBot command files, etc.)
265
- * cannot tell the difference except for pacing under burst load.
266
- */
267
- function wrapSendMessage(originalSendMessage, ctx, projectConfig) {
268
- const config = buildConfig(ctx, projectConfig);
269
- if (!config.enabled) return originalSendMessage;
270
-
271
- const limiter = new OutboundRateLimiter(originalSendMessage, config);
272
-
273
- const wrapped = function sendMessage(msg, threadID, callback, replyToMessage, isSingleUser) {
274
- return limiter.send(msg, threadID, callback, replyToMessage, isSingleUser);
275
- };
276
-
277
- wrapped.getRateLimiterStats = () => limiter.getStats();
278
- wrapped._rateLimiterConfig = config;
279
- wrapped._rateLimiterInstance = limiter;
280
-
281
- return wrapped;
282
- }
283
-
284
- module.exports = { wrapSendMessage, OutboundRateLimiter, DEFAULTS };