@eryxenx/fca 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +7 -0
- package/module/login.js +2 -1
- package/module/loginHelper.js +1 -2
- package/module/options.js +4 -0
- package/package.json +1 -1
- package/src/api/socket/listenE2EE.js +10 -0
- package/src/api/socket/listenMqtt.js +19 -0
- package/src/utils/humanizer.js +201 -0
- package/src/utils/outboundRateLimit.js +0 -284
package/index.js
CHANGED
|
@@ -5,6 +5,7 @@ const { createFcaClient } = require("./src/app/createFcaClient");
|
|
|
5
5
|
const { MessengerBot, createMessengerBot } = require("./src/app/MessengerBot");
|
|
6
6
|
const { MessengerContext } = require("./src/app/MessengerContext");
|
|
7
7
|
const { attachThreadInfoRealtimeSync, applyThreadInfoRealtimeEvent } = require("./src/app/threadInfoRealtimeSync");
|
|
8
|
+
const humanizer = require("./src/utils/humanizer");
|
|
8
9
|
|
|
9
10
|
// CommonJS default export — the login function (classic FCA / GoatBot compatible)
|
|
10
11
|
module.exports = login;
|
|
@@ -20,3 +21,9 @@ module.exports.createMessengerBot = createMessengerBot;
|
|
|
20
21
|
module.exports.MessengerContext = MessengerContext;
|
|
21
22
|
module.exports.attachThreadInfoRealtimeSync = attachThreadInfoRealtimeSync;
|
|
22
23
|
module.exports.applyThreadInfoRealtimeEvent = applyThreadInfoRealtimeEvent;
|
|
24
|
+
|
|
25
|
+
// Command anti-spam / humanizer
|
|
26
|
+
module.exports.CommandHumanizer = humanizer.CommandHumanizer;
|
|
27
|
+
module.exports.createCommandHumanizer = humanizer.createCommandHumanizer;
|
|
28
|
+
module.exports.createHumanizerMiddleware = humanizer.createHumanizerMiddleware;
|
|
29
|
+
module.exports.humanizer = humanizer;
|
package/module/login.js
CHANGED
|
@@ -98,7 +98,8 @@ function login(loginData, options, callback) {
|
|
|
98
98
|
autoReconnect: true,
|
|
99
99
|
online: true,
|
|
100
100
|
emitReady: false,
|
|
101
|
-
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
|
101
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
|
102
|
+
humanize: undefined
|
|
102
103
|
};
|
|
103
104
|
setOptions(globalOptions, options);
|
|
104
105
|
let prCallback = null;
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eryxenx/fca",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Facebook Chat API by EryXenX | Stable • Auto Re-login • Full E2EE Support — send messages, media, reactions & more in encrypted chats, hassle-free",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -64,6 +64,16 @@ module.exports = function (defaultFuncs, api, ctx) {
|
|
|
64
64
|
if (!ctx.globalOptions.selfListen && event.senderID === ctx.userID) return;
|
|
65
65
|
|
|
66
66
|
event.isE2EE = true;
|
|
67
|
+
|
|
68
|
+
if (ctx._middleware && ctx._middleware.count > 0) {
|
|
69
|
+
ctx._middleware.process(event, function (middlewareErr, processedEvent) {
|
|
70
|
+
if (middlewareErr) return globalCallback(middlewareErr, null);
|
|
71
|
+
if (processedEvent === null) return;
|
|
72
|
+
globalCallback(null, processedEvent);
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
globalCallback(null, event);
|
|
68
78
|
});
|
|
69
79
|
|
|
@@ -17,6 +17,7 @@ const createGetSeqID = require("./core/getSeqID");
|
|
|
17
17
|
const getTaskResponseData = require("./core/getTaskResponseData");
|
|
18
18
|
const createEmitAuth = require("./core/emitAuth");
|
|
19
19
|
const createMiddlewareSystem = require("./middleware");
|
|
20
|
+
const { createCommandHumanizer, createHumanizerMiddleware } = require("../../utils/humanizer");
|
|
20
21
|
|
|
21
22
|
// Auto-cycle is OFF by default now: a real browser keeps one MQTT socket
|
|
22
23
|
// open for hours. Cycling every exactly-3600.000s (full unsubscribe,
|
|
@@ -50,6 +51,24 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
50
51
|
}
|
|
51
52
|
const middleware = ctx._middleware;
|
|
52
53
|
|
|
54
|
+
if (!ctx._humanizer) {
|
|
55
|
+
ctx._humanizer = createCommandHumanizer(ctx.globalOptions.humanize);
|
|
56
|
+
}
|
|
57
|
+
if (!ctx._humanizerMiddlewareInstalled) {
|
|
58
|
+
const humanizeEnabled = ctx.globalOptions.humanize === undefined ||
|
|
59
|
+
(ctx.globalOptions.humanize && ctx.globalOptions.humanize.enabled !== false);
|
|
60
|
+
if (humanizeEnabled) {
|
|
61
|
+
try {
|
|
62
|
+
middleware.use("humanizer", createHumanizerMiddleware(ctx._humanizer, logger));
|
|
63
|
+
ctx._humanizerMiddlewareInstalled = true;
|
|
64
|
+
logger("Humanizer middleware installed (command anti-spam active)", "info");
|
|
65
|
+
} catch (e) {
|
|
66
|
+
logger(`Humanizer middleware install failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
api.getHumanizer = () => ctx._humanizer || null;
|
|
71
|
+
|
|
53
72
|
function installPostGuard() {
|
|
54
73
|
if (ctx._postGuarded) return defaultFuncs.post;
|
|
55
74
|
const rawPost = defaultFuncs.post && defaultFuncs.post.bind(defaultFuncs);
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OPTIONS = Object.freeze({
|
|
4
|
+
enabled: true,
|
|
5
|
+
reactDelayMs: [2000, 3000],
|
|
6
|
+
cooldownMs: 5500,
|
|
7
|
+
resetMs: 10000,
|
|
8
|
+
maxPerMinute: 20,
|
|
9
|
+
commands: ["/", "!", "."],
|
|
10
|
+
perThread: true
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function isCommandLike(body, prefixes) {
|
|
14
|
+
if (typeof body !== "string") return false;
|
|
15
|
+
const text = body.trim();
|
|
16
|
+
if (!text || !Array.isArray(prefixes) || !prefixes.length) return false;
|
|
17
|
+
return prefixes.some(p => typeof p === "string" && p.length > 0 && text.startsWith(p));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNoPrefixAdminCommand(event) {
|
|
21
|
+
try {
|
|
22
|
+
const GoatBot = typeof global !== "undefined" ? global.GoatBot : undefined;
|
|
23
|
+
if (!GoatBot || !GoatBot.config || !GoatBot.commands) return false;
|
|
24
|
+
|
|
25
|
+
const cfg = GoatBot.config;
|
|
26
|
+
if (!cfg.noPrefix || cfg.noPrefix.enable !== true) return false;
|
|
27
|
+
if (!Array.isArray(cfg.adminBot) || !cfg.adminBot.includes(event.senderID)) return false;
|
|
28
|
+
|
|
29
|
+
const body = typeof event.body === "string" ? event.body.trim() : "";
|
|
30
|
+
if (!body) return false;
|
|
31
|
+
|
|
32
|
+
const possibleCmd = body.split(/ +/)[0].toLowerCase();
|
|
33
|
+
if (GoatBot.commands.has(possibleCmd)) return true;
|
|
34
|
+
|
|
35
|
+
const alias = GoatBot.aliases && GoatBot.aliases.get(possibleCmd);
|
|
36
|
+
return Boolean(alias && GoatBot.commands.has(alias));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function randomBetween(min, max) {
|
|
43
|
+
if (Array.isArray(min)) {
|
|
44
|
+
const pair = min;
|
|
45
|
+
min = pair[0];
|
|
46
|
+
max = pair[1];
|
|
47
|
+
}
|
|
48
|
+
min = Number(min) || 0;
|
|
49
|
+
max = Number(max) || min;
|
|
50
|
+
if (max < min) { const t = min; min = max; max = t; }
|
|
51
|
+
return min + Math.floor(Math.random() * (max - min + 1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class CommandHumanizer {
|
|
55
|
+
constructor(options) {
|
|
56
|
+
this.setOptions(options);
|
|
57
|
+
this._threads = new Map();
|
|
58
|
+
this._global = { windowStart: 0, count: 0, accepted: 0, dropped: 0 };
|
|
59
|
+
this._eventCount = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setOptions(options) {
|
|
63
|
+
const o = options || {};
|
|
64
|
+
this.enabled = o.enabled !== false;
|
|
65
|
+
this.reactDelayMs = o.reactDelayMs || DEFAULT_OPTIONS.reactDelayMs;
|
|
66
|
+
this.cooldownMs = typeof o.cooldownMs === "number" ? o.cooldownMs : DEFAULT_OPTIONS.cooldownMs;
|
|
67
|
+
this.resetMs = typeof o.resetMs === "number" ? o.resetMs : DEFAULT_OPTIONS.resetMs;
|
|
68
|
+
this.maxPerMinute = typeof o.maxPerMinute === "number" ? o.maxPerMinute : DEFAULT_OPTIONS.maxPerMinute;
|
|
69
|
+
this.prefixes = o.commands || o.prefixes || DEFAULT_OPTIONS.commands;
|
|
70
|
+
this.perThread = o.perThread !== false;
|
|
71
|
+
if (o.matcher !== undefined) {
|
|
72
|
+
this.matcher = typeof o.matcher === "function" ? o.matcher : null;
|
|
73
|
+
} else if (this.matcher === undefined) {
|
|
74
|
+
this.matcher = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setMatcher(fn) {
|
|
79
|
+
this.matcher = typeof fn === "function" ? fn : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isCommand(event) {
|
|
83
|
+
if (!event) return false;
|
|
84
|
+
if (isCommandLike(event.body, this.prefixes)) return true;
|
|
85
|
+
if (typeof this.matcher === "function") {
|
|
86
|
+
try {
|
|
87
|
+
if (this.matcher(event)) return true;
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore matcher errors, fall through to built-in detection
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return isNoPrefixAdminCommand(event);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_keyFor(event) {
|
|
96
|
+
return this.perThread && event.threadID ? String(event.threadID) : "_global";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_globalAllowed() {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (now - this._global.windowStart >= 60000) {
|
|
102
|
+
this._global.windowStart = now;
|
|
103
|
+
this._global.count = 0;
|
|
104
|
+
}
|
|
105
|
+
if (this._global.count >= this.maxPerMinute) return false;
|
|
106
|
+
this._global.count++;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
shouldProcess(event) {
|
|
111
|
+
this._eventCount++;
|
|
112
|
+
if (!this.enabled) return { ok: true, delayMs: 0 };
|
|
113
|
+
|
|
114
|
+
const key = this._keyFor(event);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
|
|
117
|
+
let st = this._threads.get(key);
|
|
118
|
+
if (!st) {
|
|
119
|
+
st = { acceptedAt: 0, coolUntil: 0, lastCmdAt: 0, accepted: 0, dropped: 0 };
|
|
120
|
+
this._threads.set(key, st);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (st.lastCmdAt > 0 && (now - st.lastCmdAt >= this.resetMs)) {
|
|
124
|
+
st.acceptedAt = 0;
|
|
125
|
+
st.coolUntil = 0;
|
|
126
|
+
}
|
|
127
|
+
st.lastCmdAt = now;
|
|
128
|
+
|
|
129
|
+
if (st.coolUntil > 0 && now < st.coolUntil) {
|
|
130
|
+
st.dropped++;
|
|
131
|
+
this._global.dropped++;
|
|
132
|
+
return { ok: false, delayMs: 0, reason: "cooldown" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.maxPerMinute > 0 && !this._globalAllowed()) {
|
|
136
|
+
st.dropped++;
|
|
137
|
+
this._global.dropped++;
|
|
138
|
+
return { ok: false, delayMs: 0, reason: "global-cap" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
st.acceptedAt = now;
|
|
142
|
+
st.coolUntil = now + this.cooldownMs;
|
|
143
|
+
st.accepted++;
|
|
144
|
+
this._global.accepted++;
|
|
145
|
+
return { ok: true, delayMs: randomBetween(this.reactDelayMs) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
reset(key) {
|
|
149
|
+
if (key === undefined) { this._threads.clear(); return; }
|
|
150
|
+
this._threads.delete(String(key));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stats() {
|
|
154
|
+
const threads = [];
|
|
155
|
+
this._threads.forEach((st, k) => threads.push({ threadID: k, ...st }));
|
|
156
|
+
return {
|
|
157
|
+
enabled: this.enabled,
|
|
158
|
+
global: { ...this._global },
|
|
159
|
+
eventCount: this._eventCount,
|
|
160
|
+
threads
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createCommandHumanizer(options) {
|
|
166
|
+
return new CommandHumanizer(options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createHumanizerMiddleware(humanizer, logger) {
|
|
170
|
+
const log = typeof logger === "function"
|
|
171
|
+
? (msg, level) => logger(msg, level || "info")
|
|
172
|
+
: () => {};
|
|
173
|
+
|
|
174
|
+
return function commandHumanizerMiddleware(event, next) {
|
|
175
|
+
if (!humanizer.isCommand(event)) { next(); return; }
|
|
176
|
+
|
|
177
|
+
const decision = humanizer.shouldProcess(event);
|
|
178
|
+
|
|
179
|
+
if (!decision.ok) {
|
|
180
|
+
const snippet = typeof event.body === "string" ? event.body.slice(0, 40) : String(event.body || "");
|
|
181
|
+
log(`Humanizer: dropped command "${snippet}" (${decision.reason})`, "warn");
|
|
182
|
+
next(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const delay = Math.max(0, decision.delayMs || 0);
|
|
187
|
+
if (delay <= 0) { next(); return; }
|
|
188
|
+
|
|
189
|
+
setTimeout(() => next(), delay);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
CommandHumanizer,
|
|
195
|
+
createCommandHumanizer,
|
|
196
|
+
createHumanizerMiddleware,
|
|
197
|
+
randomBetween,
|
|
198
|
+
isCommandLike,
|
|
199
|
+
isNoPrefixAdminCommand,
|
|
200
|
+
DEFAULT_OPTIONS
|
|
201
|
+
};
|
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const logger = require("./nexca-logger");
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Global outbound rate-limit / anti-spam layer for api.sendMessage().
|
|
7
|
-
*
|
|
8
|
-
* This module has nothing to do with hiding bot traffic from Facebook
|
|
9
|
-
* (see antiSuspension.js for that, unrelated / untouched by this file).
|
|
10
|
-
* Its only job is to stop a single thread/user from being able to make a
|
|
11
|
-
* bot fire an unbounded burst of outgoing replies, and to stop many busy
|
|
12
|
-
* threads at once from overwhelming the FCA send path.
|
|
13
|
-
*
|
|
14
|
-
* Design:
|
|
15
|
-
* - Per-thread sliding window: at most `maxPerWindow` sends per thread
|
|
16
|
-
* every `windowMs`. Extra sends for that thread are queued, not sent
|
|
17
|
-
* immediately.
|
|
18
|
-
* - Global concurrency cap: at most `maxConcurrentSends` sendMessage
|
|
19
|
-
* calls in flight at once, across all threads.
|
|
20
|
-
* - Bounded queue: at most `maxQueueSize` messages waiting at once. Once
|
|
21
|
-
* full, new sends are rejected immediately (never silently grows
|
|
22
|
-
* without bound).
|
|
23
|
-
* - Queue TTL: a queued message that has been waiting longer than
|
|
24
|
-
* `maxQueueWaitMs` is dropped instead of being sent very late.
|
|
25
|
-
* - Optional short-window duplicate suppression (off by default).
|
|
26
|
-
*
|
|
27
|
-
* This wraps the *existing* sendMessage function — it never re-implements
|
|
28
|
-
* sending, never retries a failed send, and never changes what happens on
|
|
29
|
-
* success/failure. It only decides *when* the real sendMessage gets
|
|
30
|
-
* called, and calls it at most once per accepted message.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
const DEFAULTS = {
|
|
34
|
-
enabled: true,
|
|
35
|
-
windowMs: 10000, // RATE_LIMIT_WINDOW
|
|
36
|
-
maxPerWindow: 3, // MAX_MESSAGES_PER_WINDOW (per thread/user)
|
|
37
|
-
maxQueueSize: 500, // MAX_QUEUE_SIZE (global)
|
|
38
|
-
maxConcurrentSends: 5, // MAX_CONCURRENT_SENDS (global)
|
|
39
|
-
maxQueueWaitMs: 20000, // drop a queued message after this long
|
|
40
|
-
queueTickMs: 300, // how often to re-check a blocked queue
|
|
41
|
-
dedupe: {
|
|
42
|
-
enabled: false, // opt-in: suppress identical back-to-back text
|
|
43
|
-
windowMs: 1500
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
function buildConfig(ctx, projectConfig) {
|
|
48
|
-
const fromFile = (projectConfig && projectConfig.rateLimiter) || {};
|
|
49
|
-
const fromLoginOpts = (ctx && ctx.globalOptions && ctx.globalOptions.rateLimiter) || {};
|
|
50
|
-
|
|
51
|
-
const merged = Object.assign({}, DEFAULTS, fromFile, fromLoginOpts);
|
|
52
|
-
merged.dedupe = Object.assign({}, DEFAULTS.dedupe, fromFile.dedupe, fromLoginOpts.dedupe);
|
|
53
|
-
|
|
54
|
-
// Same opt-out convention already used for antiBan in sendMessage.js:
|
|
55
|
-
// globalOptions.outboundRateLimit === false disables this layer entirely.
|
|
56
|
-
if (ctx && ctx.globalOptions && ctx.globalOptions.outboundRateLimit === false) {
|
|
57
|
-
merged.enabled = false;
|
|
58
|
-
}
|
|
59
|
-
return merged;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeDedupeBody(msg) {
|
|
63
|
-
// Only dedupe plain text sends. Attachments/stickers/locations etc. are
|
|
64
|
-
// left alone since "identical" is much less meaningful for them and the
|
|
65
|
-
// risk of dropping something the bot actually meant to (re)send is higher.
|
|
66
|
-
if (typeof msg === "string") return msg;
|
|
67
|
-
if (msg && typeof msg === "object" && !msg.attachment && typeof msg.body === "string") {
|
|
68
|
-
return msg.body;
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
class OutboundRateLimiter {
|
|
74
|
-
constructor(originalSendMessage, config) {
|
|
75
|
-
this.originalSendMessage = originalSendMessage;
|
|
76
|
-
this.config = config;
|
|
77
|
-
|
|
78
|
-
this.perThread = new Map(); // threadKey -> [timestamps]
|
|
79
|
-
this.dedupeCache = new Map(); // threadKey -> { body, at }
|
|
80
|
-
this.queue = []; // [{ msg, threadID, replyToMessage, isSingleUser, key, settle, enqueuedAt }]
|
|
81
|
-
this.activeSends = 0;
|
|
82
|
-
this._processing = false;
|
|
83
|
-
this._retryTimer = null;
|
|
84
|
-
|
|
85
|
-
this._gcTimer = setInterval(() => this._gc(), 60000);
|
|
86
|
-
if (this._gcTimer.unref) this._gcTimer.unref();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
destroy() {
|
|
90
|
-
if (this._gcTimer) clearInterval(this._gcTimer);
|
|
91
|
-
if (this._retryTimer) clearTimeout(this._retryTimer);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
getStats() {
|
|
95
|
-
return {
|
|
96
|
-
queued: this.queue.length,
|
|
97
|
-
activeSends: this.activeSends,
|
|
98
|
-
trackedThreads: this.perThread.size
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
_gc() {
|
|
103
|
-
const now = Date.now();
|
|
104
|
-
for (const [key, timestamps] of this.perThread) {
|
|
105
|
-
while (timestamps.length && timestamps[0] <= now - this.config.windowMs) timestamps.shift();
|
|
106
|
-
if (!timestamps.length) this.perThread.delete(key);
|
|
107
|
-
}
|
|
108
|
-
for (const [key, entry] of this.dedupeCache) {
|
|
109
|
-
if (now - entry.at > this.config.dedupe.windowMs) this.dedupeCache.delete(key);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
_isRateLimited(key) {
|
|
114
|
-
const timestamps = this.perThread.get(key);
|
|
115
|
-
if (!timestamps || !timestamps.length) return false;
|
|
116
|
-
const cutoff = Date.now() - this.config.windowMs;
|
|
117
|
-
while (timestamps.length && timestamps[0] <= cutoff) timestamps.shift();
|
|
118
|
-
return timestamps.length >= this.config.maxPerWindow;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
_recordSend(key) {
|
|
122
|
-
let timestamps = this.perThread.get(key);
|
|
123
|
-
if (!timestamps) {
|
|
124
|
-
timestamps = [];
|
|
125
|
-
this.perThread.set(key, timestamps);
|
|
126
|
-
}
|
|
127
|
-
timestamps.push(Date.now());
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
_isDuplicate(key, msg) {
|
|
131
|
-
if (!this.config.dedupe.enabled) return false;
|
|
132
|
-
const body = normalizeDedupeBody(msg);
|
|
133
|
-
if (body === null) return false;
|
|
134
|
-
const last = this.dedupeCache.get(key);
|
|
135
|
-
const now = Date.now();
|
|
136
|
-
if (last && last.body === body && (now - last.at) < this.config.dedupe.windowMs) {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
this.dedupeCache.set(key, { body, at: now });
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
_sweepExpired() {
|
|
144
|
-
const now = Date.now();
|
|
145
|
-
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
146
|
-
const job = this.queue[i];
|
|
147
|
-
if (now - job.enqueuedAt > this.config.maxQueueWaitMs) {
|
|
148
|
-
this.queue.splice(i, 1);
|
|
149
|
-
job.settle({ error: "sendMessage: queued message expired before it could be sent (rate-limit backlog)" });
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
_processQueue() {
|
|
155
|
-
if (this._processing) return;
|
|
156
|
-
this._processing = true;
|
|
157
|
-
try {
|
|
158
|
-
this._sweepExpired();
|
|
159
|
-
let i = 0;
|
|
160
|
-
while (i < this.queue.length && this.activeSends < this.config.maxConcurrentSends) {
|
|
161
|
-
const job = this.queue[i];
|
|
162
|
-
if (this._isRateLimited(job.key)) {
|
|
163
|
-
i++;
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
this.queue.splice(i, 1);
|
|
167
|
-
this._dispatch(job);
|
|
168
|
-
}
|
|
169
|
-
} finally {
|
|
170
|
-
this._processing = false;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (this._retryTimer) return;
|
|
174
|
-
if (!this.queue.length) return;
|
|
175
|
-
this._retryTimer = setTimeout(() => {
|
|
176
|
-
this._retryTimer = null;
|
|
177
|
-
this._processQueue();
|
|
178
|
-
}, this.config.queueTickMs);
|
|
179
|
-
if (this._retryTimer.unref) this._retryTimer.unref();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
_callOriginal(job) {
|
|
183
|
-
const original = this.originalSendMessage;
|
|
184
|
-
return new Promise((resolve, reject) => {
|
|
185
|
-
try {
|
|
186
|
-
original(job.msg, job.threadID, (err, result) => {
|
|
187
|
-
if (err) reject(err); else resolve(result);
|
|
188
|
-
}, job.replyToMessage, job.isSingleUser);
|
|
189
|
-
} catch (syncErr) {
|
|
190
|
-
reject(syncErr);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async _dispatch(job) {
|
|
196
|
-
this.activeSends++;
|
|
197
|
-
this._recordSend(job.key);
|
|
198
|
-
try {
|
|
199
|
-
const result = await this._callOriginal(job);
|
|
200
|
-
job.settle(null, result);
|
|
201
|
-
} catch (err) {
|
|
202
|
-
// No automatic retry here on purpose — a failed send stays failed,
|
|
203
|
-
// exactly like calling the un-wrapped sendMessage would behave.
|
|
204
|
-
job.settle(err);
|
|
205
|
-
} finally {
|
|
206
|
-
this.activeSends--;
|
|
207
|
-
this._processQueue();
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
send(msg, threadID, callback, replyToMessage, isSingleUser) {
|
|
212
|
-
if (typeof callback !== "function") callback = null;
|
|
213
|
-
|
|
214
|
-
let resolveOuter, rejectOuter;
|
|
215
|
-
const outerPromise = new Promise((res, rej) => {
|
|
216
|
-
resolveOuter = res;
|
|
217
|
-
rejectOuter = rej;
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
let settled = false;
|
|
221
|
-
const settle = (err, result) => {
|
|
222
|
-
if (settled) return;
|
|
223
|
-
settled = true;
|
|
224
|
-
if (callback) {
|
|
225
|
-
try {
|
|
226
|
-
callback(err || null, result);
|
|
227
|
-
} catch (cbErr) {
|
|
228
|
-
logger.warn("RateLimiter", "sendMessage callback threw: " + (cbErr && cbErr.message ? cbErr.message : cbErr));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (err) rejectOuter(err); else resolveOuter(result);
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const key = threadID === undefined || threadID === null ? "unknown" : String(threadID);
|
|
235
|
-
|
|
236
|
-
if (this._isDuplicate(key, msg)) {
|
|
237
|
-
settle(null, { skipped: true, reason: "duplicate" });
|
|
238
|
-
return outerPromise;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (this.queue.length >= this.config.maxQueueSize) {
|
|
242
|
-
settle({ error: "sendMessage: outbound queue is full, message dropped" });
|
|
243
|
-
return outerPromise;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
this.queue.push({
|
|
247
|
-
msg,
|
|
248
|
-
threadID,
|
|
249
|
-
replyToMessage,
|
|
250
|
-
isSingleUser,
|
|
251
|
-
key,
|
|
252
|
-
settle,
|
|
253
|
-
enqueuedAt: Date.now()
|
|
254
|
-
});
|
|
255
|
-
this._processQueue();
|
|
256
|
-
|
|
257
|
-
return outerPromise;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Wraps an existing sendMessage(msg, threadID, callback, replyToMessage,
|
|
263
|
-
* isSingleUser) function with the rate-limit/queue layer above. Same
|
|
264
|
-
* signature in, same signature out — callers (GoatBot command files, etc.)
|
|
265
|
-
* cannot tell the difference except for pacing under burst load.
|
|
266
|
-
*/
|
|
267
|
-
function wrapSendMessage(originalSendMessage, ctx, projectConfig) {
|
|
268
|
-
const config = buildConfig(ctx, projectConfig);
|
|
269
|
-
if (!config.enabled) return originalSendMessage;
|
|
270
|
-
|
|
271
|
-
const limiter = new OutboundRateLimiter(originalSendMessage, config);
|
|
272
|
-
|
|
273
|
-
const wrapped = function sendMessage(msg, threadID, callback, replyToMessage, isSingleUser) {
|
|
274
|
-
return limiter.send(msg, threadID, callback, replyToMessage, isSingleUser);
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
wrapped.getRateLimiterStats = () => limiter.getStats();
|
|
278
|
-
wrapped._rateLimiterConfig = config;
|
|
279
|
-
wrapped._rateLimiterInstance = limiter;
|
|
280
|
-
|
|
281
|
-
return wrapped;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
module.exports = { wrapSendMessage, OutboundRateLimiter, DEFAULTS };
|