@eryxenx/fca 1.1.0 → 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/index.js CHANGED
@@ -5,6 +5,7 @@ const { createFcaClient } = require("./src/app/createFcaClient");
5
5
  const { MessengerBot, createMessengerBot } = require("./src/app/MessengerBot");
6
6
  const { MessengerContext } = require("./src/app/MessengerContext");
7
7
  const { attachThreadInfoRealtimeSync, applyThreadInfoRealtimeEvent } = require("./src/app/threadInfoRealtimeSync");
8
+ const humanizer = require("./src/utils/humanizer");
8
9
 
9
10
  // CommonJS default export — the login function (classic FCA / GoatBot compatible)
10
11
  module.exports = login;
@@ -20,3 +21,9 @@ module.exports.createMessengerBot = createMessengerBot;
20
21
  module.exports.MessengerContext = MessengerContext;
21
22
  module.exports.attachThreadInfoRealtimeSync = attachThreadInfoRealtimeSync;
22
23
  module.exports.applyThreadInfoRealtimeEvent = applyThreadInfoRealtimeEvent;
24
+
25
+ // Command anti-spam / humanizer
26
+ module.exports.CommandHumanizer = humanizer.CommandHumanizer;
27
+ module.exports.createCommandHumanizer = humanizer.createCommandHumanizer;
28
+ module.exports.createHumanizerMiddleware = humanizer.createHumanizerMiddleware;
29
+ module.exports.humanizer = humanizer;
package/module/login.js CHANGED
@@ -98,7 +98,8 @@ function login(loginData, options, callback) {
98
98
  autoReconnect: true,
99
99
  online: true,
100
100
  emitReady: false,
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"
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
+ humanize: undefined
102
103
  };
103
104
  setOptions(globalOptions, options);
104
105
  let prCallback = 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eryxenx/fca",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
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",
@@ -64,6 +64,16 @@ module.exports = function (defaultFuncs, api, ctx) {
64
64
  if (!ctx.globalOptions.selfListen && event.senderID === ctx.userID) return;
65
65
 
66
66
  event.isE2EE = true;
67
+
68
+ if (ctx._middleware && ctx._middleware.count > 0) {
69
+ ctx._middleware.process(event, function (middlewareErr, processedEvent) {
70
+ if (middlewareErr) return globalCallback(middlewareErr, null);
71
+ if (processedEvent === null) return;
72
+ globalCallback(null, processedEvent);
73
+ });
74
+ return;
75
+ }
76
+
67
77
  globalCallback(null, event);
68
78
  });
69
79
 
@@ -17,6 +17,7 @@ const createGetSeqID = require("./core/getSeqID");
17
17
  const getTaskResponseData = require("./core/getTaskResponseData");
18
18
  const createEmitAuth = require("./core/emitAuth");
19
19
  const createMiddlewareSystem = require("./middleware");
20
+ const { createCommandHumanizer, createHumanizerMiddleware } = require("../../utils/humanizer");
20
21
 
21
22
  // Auto-cycle is OFF by default now: a real browser keeps one MQTT socket
22
23
  // open for hours. Cycling every exactly-3600.000s (full unsubscribe,
@@ -50,6 +51,24 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
50
51
  }
51
52
  const middleware = ctx._middleware;
52
53
 
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
+ }
68
+ }
69
+ }
70
+ api.getHumanizer = () => ctx._humanizer || null;
71
+
53
72
  function installPostGuard() {
54
73
  if (ctx._postGuarded) return defaultFuncs.post;
55
74
  const rawPost = defaultFuncs.post && defaultFuncs.post.bind(defaultFuncs);
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_OPTIONS = Object.freeze({
4
+ enabled: true,
5
+ reactDelayMs: [2000, 3000],
6
+ cooldownMs: 5500,
7
+ resetMs: 10000,
8
+ maxPerMinute: 20,
9
+ commands: ["/", "!", "."],
10
+ perThread: true
11
+ });
12
+
13
+ function isCommandLike(body, prefixes) {
14
+ if (typeof body !== "string") return false;
15
+ const text = body.trim();
16
+ if (!text || !Array.isArray(prefixes) || !prefixes.length) return false;
17
+ return prefixes.some(p => typeof p === "string" && p.length > 0 && text.startsWith(p));
18
+ }
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
+ function randomBetween(min, max) {
43
+ if (Array.isArray(min)) {
44
+ const pair = min;
45
+ min = pair[0];
46
+ max = pair[1];
47
+ }
48
+ min = Number(min) || 0;
49
+ max = Number(max) || min;
50
+ if (max < min) { const t = min; min = max; max = t; }
51
+ return min + Math.floor(Math.random() * (max - min + 1));
52
+ }
53
+
54
+ class CommandHumanizer {
55
+ constructor(options) {
56
+ this.setOptions(options);
57
+ this._threads = new Map();
58
+ this._global = { windowStart: 0, count: 0, accepted: 0, dropped: 0 };
59
+ this._eventCount = 0;
60
+ }
61
+
62
+ setOptions(options) {
63
+ const o = options || {};
64
+ this.enabled = o.enabled !== false;
65
+ this.reactDelayMs = o.reactDelayMs || DEFAULT_OPTIONS.reactDelayMs;
66
+ this.cooldownMs = typeof o.cooldownMs === "number" ? o.cooldownMs : DEFAULT_OPTIONS.cooldownMs;
67
+ this.resetMs = typeof o.resetMs === "number" ? o.resetMs : DEFAULT_OPTIONS.resetMs;
68
+ this.maxPerMinute = typeof o.maxPerMinute === "number" ? o.maxPerMinute : DEFAULT_OPTIONS.maxPerMinute;
69
+ this.prefixes = o.commands || o.prefixes || DEFAULT_OPTIONS.commands;
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;
80
+ }
81
+
82
+ isCommand(event) {
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);
93
+ }
94
+
95
+ _keyFor(event) {
96
+ return this.perThread && event.threadID ? String(event.threadID) : "_global";
97
+ }
98
+
99
+ _globalAllowed() {
100
+ const now = Date.now();
101
+ if (now - this._global.windowStart >= 60000) {
102
+ this._global.windowStart = now;
103
+ this._global.count = 0;
104
+ }
105
+ if (this._global.count >= this.maxPerMinute) return false;
106
+ this._global.count++;
107
+ return true;
108
+ }
109
+
110
+ shouldProcess(event) {
111
+ this._eventCount++;
112
+ if (!this.enabled) return { ok: true, delayMs: 0 };
113
+
114
+ const key = this._keyFor(event);
115
+ const now = Date.now();
116
+
117
+ let st = this._threads.get(key);
118
+ if (!st) {
119
+ st = { acceptedAt: 0, coolUntil: 0, lastCmdAt: 0, accepted: 0, dropped: 0 };
120
+ this._threads.set(key, st);
121
+ }
122
+
123
+ if (st.lastCmdAt > 0 && (now - st.lastCmdAt >= this.resetMs)) {
124
+ st.acceptedAt = 0;
125
+ st.coolUntil = 0;
126
+ }
127
+ st.lastCmdAt = now;
128
+
129
+ if (st.coolUntil > 0 && now < st.coolUntil) {
130
+ st.dropped++;
131
+ this._global.dropped++;
132
+ return { ok: false, delayMs: 0, reason: "cooldown" };
133
+ }
134
+
135
+ if (this.maxPerMinute > 0 && !this._globalAllowed()) {
136
+ st.dropped++;
137
+ this._global.dropped++;
138
+ return { ok: false, delayMs: 0, reason: "global-cap" };
139
+ }
140
+
141
+ st.acceptedAt = now;
142
+ st.coolUntil = now + this.cooldownMs;
143
+ st.accepted++;
144
+ this._global.accepted++;
145
+ return { ok: true, delayMs: randomBetween(this.reactDelayMs) };
146
+ }
147
+
148
+ reset(key) {
149
+ if (key === undefined) { this._threads.clear(); return; }
150
+ this._threads.delete(String(key));
151
+ }
152
+
153
+ stats() {
154
+ const threads = [];
155
+ this._threads.forEach((st, k) => threads.push({ threadID: k, ...st }));
156
+ return {
157
+ enabled: this.enabled,
158
+ global: { ...this._global },
159
+ eventCount: this._eventCount,
160
+ threads
161
+ };
162
+ }
163
+ }
164
+
165
+ function createCommandHumanizer(options) {
166
+ return new CommandHumanizer(options);
167
+ }
168
+
169
+ function createHumanizerMiddleware(humanizer, logger) {
170
+ const log = typeof logger === "function"
171
+ ? (msg, level) => logger(msg, level || "info")
172
+ : () => {};
173
+
174
+ return function commandHumanizerMiddleware(event, next) {
175
+ if (!humanizer.isCommand(event)) { next(); return; }
176
+
177
+ const decision = humanizer.shouldProcess(event);
178
+
179
+ if (!decision.ok) {
180
+ const snippet = typeof event.body === "string" ? event.body.slice(0, 40) : String(event.body || "");
181
+ log(`Humanizer: dropped command "${snippet}" (${decision.reason})`, "warn");
182
+ next(false);
183
+ return;
184
+ }
185
+
186
+ const delay = Math.max(0, decision.delayMs || 0);
187
+ if (delay <= 0) { next(); return; }
188
+
189
+ setTimeout(() => next(), delay);
190
+ };
191
+ }
192
+
193
+ module.exports = {
194
+ CommandHumanizer,
195
+ createCommandHumanizer,
196
+ createHumanizerMiddleware,
197
+ randomBetween,
198
+ isCommandLike,
199
+ isNoPrefixAdminCommand,
200
+ DEFAULT_OPTIONS
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 };