@eryxenx/fca 1.1.1 → 1.1.3
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 +0 -104
- package/module/login.js +0 -3
- package/module/loginHelper.js +1 -2
- package/module/options.js +4 -4
- package/package.json +2 -2
- package/src/api/socket/listenE2EE.js +5 -12
- package/src/api/socket/listenMqtt.js +15 -28
- package/src/utils/humanizer.js +67 -45
- package/src/utils/outboundRateLimit.js +0 -284
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;
|
package/module/loginHelper.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support —
|
|
3
|
+
"version": "1.1.3",
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
ctx.
|
|
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, api));
|
|
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); }
|
package/src/utils/humanizer.js
CHANGED
|
@@ -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();
|
|
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
|
-
|
|
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,17 +166,21 @@ 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
|
-
function createHumanizerMiddleware(humanizer, logger) {
|
|
169
|
+
function createHumanizerMiddleware(humanizer, logger, api) {
|
|
166
170
|
const log = typeof logger === "function"
|
|
167
171
|
? (msg, level) => logger(msg, level || "info")
|
|
168
172
|
: () => {};
|
|
169
173
|
|
|
174
|
+
function typingWanted() {
|
|
175
|
+
try {
|
|
176
|
+
const GoatBot = typeof global !== "undefined" ? global.GoatBot : undefined;
|
|
177
|
+
if (GoatBot && GoatBot.config) return GoatBot.config.enableTypingIndicator === true;
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
return function commandHumanizerMiddleware(event, next) {
|
|
171
185
|
if (!humanizer.isCommand(event)) { next(); return; }
|
|
172
186
|
|
|
@@ -175,17 +189,24 @@ function createHumanizerMiddleware(humanizer, logger) {
|
|
|
175
189
|
if (!decision.ok) {
|
|
176
190
|
const snippet = typeof event.body === "string" ? event.body.slice(0, 40) : String(event.body || "");
|
|
177
191
|
log(`Humanizer: dropped command "${snippet}" (${decision.reason})`, "warn");
|
|
178
|
-
next(false);
|
|
192
|
+
next(false);
|
|
179
193
|
return;
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
const delay = Math.max(0, decision.delayMs || 0);
|
|
183
197
|
if (delay <= 0) { next(); return; }
|
|
184
198
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
const canType = api && typeof api.sendTypingIndicator === "function" && event.threadID && typingWanted();
|
|
200
|
+
if (!canType) {
|
|
201
|
+
setTimeout(() => next(), delay);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
Promise.resolve(api.sendTypingIndicator(event.threadID, true)).catch(() => {});
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
Promise.resolve(api.sendTypingIndicator(event.threadID, false)).catch(() => {});
|
|
208
|
+
next();
|
|
209
|
+
}, delay);
|
|
189
210
|
};
|
|
190
211
|
}
|
|
191
212
|
|
|
@@ -195,5 +216,6 @@ module.exports = {
|
|
|
195
216
|
createHumanizerMiddleware,
|
|
196
217
|
randomBetween,
|
|
197
218
|
isCommandLike,
|
|
219
|
+
isNoPrefixAdminCommand,
|
|
198
220
|
DEFAULT_OPTIONS
|
|
199
|
-
};
|
|
221
|
+
};
|
|
@@ -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 };
|