@eryxenx/fca 1.0.9 → 1.1.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/module/loginHelper.js +2 -1
- package/package.json +1 -1
- package/src/utils/outboundRateLimit.js +284 -0
package/module/loginHelper.js
CHANGED
|
@@ -1341,7 +1341,8 @@ 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
|
-
|
|
1344
|
+
const { wrapSendMessage } = require("../src/utils/outboundRateLimit");
|
|
1345
|
+
api.sendMessage = wrapSendMessage(nexcaSendMsg, ctxMain, config);
|
|
1345
1346
|
api.sendMessageMqtt = require("../src/api/socket/sendMessageMqtt")(defaultFuncs, api, ctxMain);
|
|
1346
1347
|
api.OldMessage = require("../src/api/socket/OldMessage")(defaultFuncs, api, ctxMain);
|
|
1347
1348
|
api.sendMessageDM = (msg, threadID, cb, replyTo) => api.OldMessage(msg, threadID, cb, replyTo, true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/fca",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
@@ -0,0 +1,284 @@
|
|
|
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 };
|