@eryxenx/fca 1.0.9 → 1.1.1
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 +104 -0
- package/index.js +7 -0
- package/module/login.js +5 -1
- package/module/loginHelper.js +2 -1
- package/module/options.js +4 -0
- package/package.json +2 -2
- package/src/api/socket/listenE2EE.js +17 -0
- package/src/api/socket/listenMqtt.js +32 -0
- package/src/utils/humanizer.js +199 -0
- package/src/utils/outboundRateLimit.js +284 -0
package/README.md
CHANGED
|
@@ -420,6 +420,109 @@ 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
|
+
|
|
423
526
|
## 📋 Login Options
|
|
424
527
|
|
|
425
528
|
| Option | Type | Default | Description |
|
|
@@ -435,6 +538,7 @@ api.uploadImageToImgbb(imageUrl);
|
|
|
435
538
|
| `emitReady` | boolean | false | Emit ready event when MQTT connected |
|
|
436
539
|
| `proxy` | string | — | HTTP proxy URL |
|
|
437
540
|
| `userAgent` | string | Safari UA | Override HTTP User-Agent |
|
|
541
|
+
| `humanize` | object | enabled | Command anti-spam config (see above) |
|
|
438
542
|
|
|
439
543
|
---
|
|
440
544
|
|
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,8 +98,12 @@ 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
|
};
|
|
104
|
+
if (options && options.humanize !== undefined) {
|
|
105
|
+
globalOptions.humanize = options.humanize;
|
|
106
|
+
}
|
|
103
107
|
setOptions(globalOptions, options);
|
|
104
108
|
let prCallback = null;
|
|
105
109
|
let rejectFunc = null;
|
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/module/options.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/fca",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support —
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — with Command Anti-Spam (Humanizer)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
var logger = require("../../utils/nexca-logger");
|
|
4
4
|
var EventEmitter = require("events");
|
|
5
|
+
var { createHumanizerMiddleware } = require("../../utils/humanizer");
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* listenE2EE — merges E2EE messages into the same event stream as MQTT.
|
|
@@ -64,6 +65,22 @@ module.exports = function (defaultFuncs, api, ctx) {
|
|
|
64
65
|
if (!ctx.globalOptions.selfListen && event.senderID === ctx.userID) return;
|
|
65
66
|
|
|
66
67
|
event.isE2EE = true;
|
|
68
|
+
|
|
69
|
+
// Run E2EE events through the same middleware pipeline as
|
|
70
|
+
// MQTT events so command anti-spam also applies here.
|
|
71
|
+
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);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
globalCallback(null, event);
|
|
68
85
|
});
|
|
69
86
|
|
|
@@ -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,18 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
50
51
|
}
|
|
51
52
|
const middleware = ctx._middleware;
|
|
52
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);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
middleware.use("humanizer", createHumanizerMiddleware(ctx._humanizer, logger));
|
|
63
|
+
logger("Humanizer middleware installed (command anti-spam active)", "info");
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
function installPostGuard() {
|
|
54
67
|
if (ctx._postGuarded) return defaultFuncs.post;
|
|
55
68
|
const rawPost = defaultFuncs.post && defaultFuncs.post.bind(defaultFuncs);
|
|
@@ -349,6 +362,25 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
349
362
|
|
|
350
363
|
const msgEmitter = new MessageEmitter();
|
|
351
364
|
|
|
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
|
+
|
|
352
384
|
// Original callback without middleware
|
|
353
385
|
const originalCallback = callback || function (error, message) {
|
|
354
386
|
if (error) { logger("mqtt emit error", "error"); return msgEmitter.emit("error", error); }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
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
|
+
const DEFAULT_OPTIONS = Object.freeze({
|
|
26
|
+
enabled: true,
|
|
27
|
+
reactDelayMs: [2000, 3000],
|
|
28
|
+
cooldownMs: 5500,
|
|
29
|
+
resetMs: 10000,
|
|
30
|
+
maxPerMinute: 20,
|
|
31
|
+
commands: ["/", "!", "."],
|
|
32
|
+
perThread: true
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function isCommandLike(body, prefixes) {
|
|
36
|
+
if (typeof body !== "string") return false;
|
|
37
|
+
const text = body.trim();
|
|
38
|
+
if (!text || !Array.isArray(prefixes) || !prefixes.length) return false;
|
|
39
|
+
return prefixes.some(p => typeof p === "string" && p.length > 0 && text.startsWith(p));
|
|
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(); // key -> state
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
isCommand(event) {
|
|
74
|
+
return Boolean(event && isCommandLike(event.body, this.prefixes));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_keyFor(event) {
|
|
78
|
+
return this.perThread && event.threadID ? String(event.threadID) : "_global";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_globalAllowed() {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (now - this._global.windowStart >= 60000) {
|
|
84
|
+
this._global.windowStart = now;
|
|
85
|
+
this._global.count = 0;
|
|
86
|
+
}
|
|
87
|
+
if (this._global.count >= this.maxPerMinute) return false;
|
|
88
|
+
this._global.count++;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
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
|
+
shouldProcess(event) {
|
|
99
|
+
this._eventCount++;
|
|
100
|
+
if (!this.enabled) return { ok: true, delayMs: 0 };
|
|
101
|
+
|
|
102
|
+
const key = this._keyFor(event);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
let st = this._threads.get(key);
|
|
106
|
+
if (!st) {
|
|
107
|
+
st = { acceptedAt: 0, coolUntil: 0, lastCmdAt: 0, accepted: 0, dropped: 0 };
|
|
108
|
+
this._threads.set(key, st);
|
|
109
|
+
}
|
|
110
|
+
|
|
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
|
+
if (st.lastCmdAt > 0 && (now - st.lastCmdAt >= this.resetMs)) {
|
|
114
|
+
st.acceptedAt = 0;
|
|
115
|
+
st.coolUntil = 0;
|
|
116
|
+
}
|
|
117
|
+
st.lastCmdAt = now;
|
|
118
|
+
|
|
119
|
+
if (st.coolUntil > 0 && now < st.coolUntil) {
|
|
120
|
+
st.dropped++;
|
|
121
|
+
this._global.dropped++;
|
|
122
|
+
return { ok: false, delayMs: 0, reason: "cooldown" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (this.maxPerMinute > 0 && !this._globalAllowed()) {
|
|
126
|
+
st.dropped++;
|
|
127
|
+
this._global.dropped++;
|
|
128
|
+
return { ok: false, delayMs: 0, reason: "global-cap" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
st.acceptedAt = now;
|
|
132
|
+
st.coolUntil = now + this.cooldownMs;
|
|
133
|
+
st.accepted++;
|
|
134
|
+
this._global.accepted++;
|
|
135
|
+
return { ok: true, delayMs: randomBetween(this.reactDelayMs) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
reset(key) {
|
|
139
|
+
if (key === undefined) { this._threads.clear(); return; }
|
|
140
|
+
this._threads.delete(String(key));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
stats() {
|
|
144
|
+
const threads = [];
|
|
145
|
+
this._threads.forEach((st, k) => threads.push({ threadID: k, ...st }));
|
|
146
|
+
return {
|
|
147
|
+
enabled: this.enabled,
|
|
148
|
+
global: { ...this._global },
|
|
149
|
+
eventCount: this._eventCount,
|
|
150
|
+
threads
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createCommandHumanizer(options) {
|
|
156
|
+
return new CommandHumanizer(options);
|
|
157
|
+
}
|
|
158
|
+
|
|
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
|
+
function createHumanizerMiddleware(humanizer, logger) {
|
|
166
|
+
const log = typeof logger === "function"
|
|
167
|
+
? (msg, level) => logger(msg, level || "info")
|
|
168
|
+
: () => {};
|
|
169
|
+
|
|
170
|
+
return function commandHumanizerMiddleware(event, next) {
|
|
171
|
+
if (!humanizer.isCommand(event)) { next(); return; }
|
|
172
|
+
|
|
173
|
+
const decision = humanizer.shouldProcess(event);
|
|
174
|
+
|
|
175
|
+
if (!decision.ok) {
|
|
176
|
+
const snippet = typeof event.body === "string" ? event.body.slice(0, 40) : String(event.body || "");
|
|
177
|
+
log(`Humanizer: dropped command "${snippet}" (${decision.reason})`, "warn");
|
|
178
|
+
next(false); // stop processing, do not emit
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const delay = Math.max(0, decision.delayMs || 0);
|
|
183
|
+
if (delay <= 0) { next(); return; }
|
|
184
|
+
|
|
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
|
+
setTimeout(() => next(), delay);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
CommandHumanizer,
|
|
194
|
+
createCommandHumanizer,
|
|
195
|
+
createHumanizerMiddleware,
|
|
196
|
+
randomBetween,
|
|
197
|
+
isCommandLike,
|
|
198
|
+
DEFAULT_OPTIONS
|
|
199
|
+
};
|
|
@@ -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 };
|