@eryxenx/fca 1.1.0 → 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 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/options.js CHANGED
@@ -34,6 +34,10 @@ function setOptions(globalOptions, options) {
34
34
  }
35
35
  break;
36
36
  }
37
+ case "humanize": {
38
+ globalOptions.humanize = options.humanize;
39
+ break;
40
+ }
37
41
  default: {
38
42
  logger("setOptions Unrecognized option given to setOptions: " + key, "warn");
39
43
  break;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eryxenx/fca",
3
- "version": "1.1.0",
4
- "description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — send messages, media, reactions & more in encrypted chats, hassle-free",
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
+ };