@manybot/manybot 5.7.0 → 5.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +56 -5
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/drivers/baileys/adapter.js +58 -7
- package/dist/drivers/baileys/api/index.js +172 -24
- package/dist/drivers/baileys/index.js +62 -30
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +158 -4
- package/dist/drivers/baileys/messageHandler.test.js +203 -0
- package/dist/drivers/baileysAdapter.test.js +281 -0
- package/dist/drivers/jid.test.js +40 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +15 -2
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +168 -0
- package/dist/kernel/commandDeprecation.test.js +107 -0
- package/dist/kernel/commandMenu.js +268 -0
- package/dist/kernel/commandMenu.test.js +234 -0
- package/dist/kernel/commandPermissions.js +125 -0
- package/dist/kernel/commandPermissions.test.js +159 -0
- package/dist/kernel/commandRegistry.js +459 -0
- package/dist/kernel/commandRegistry.test.js +156 -0
- package/dist/kernel/commandsConfig.js +517 -0
- package/dist/kernel/commandsConfig.test.js +236 -0
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +583 -0
- package/dist/kernel/pluginGuard.js +15 -12
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +96 -1
- package/dist/kernel/pluginLoader.test.js +80 -0
- package/dist/kernel/runCommand.js +245 -0
- package/dist/kernel/runCommand.test.js +235 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +4 -3
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +183 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +64 -13
- package/dist/locales/es.json +64 -13
- package/dist/locales/pt.json +64 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +167 -0
- package/dist/plugins/__manybot_integration__/index.test.js +184 -0
- package/package.json +74 -17
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import test, { describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveDispatch, runCommand, renderUsage } from "#kernel/runCommand.js";
|
|
4
|
+
import { buildCommandRegistry, __setRegistryForTests } from "#kernel/commandRegistry.js";
|
|
5
|
+
import { pluginRegistry } from "#kernel/pluginLoader.js";
|
|
6
|
+
function emptySpec(overrides) {
|
|
7
|
+
return {
|
|
8
|
+
id: overrides.id ?? "todo::add",
|
|
9
|
+
cmd: overrides.cmd ?? "todo",
|
|
10
|
+
aliases: overrides.aliases ?? [],
|
|
11
|
+
plugin: overrides.plugin ?? "todoPlugin",
|
|
12
|
+
function: overrides.function ?? "addFn",
|
|
13
|
+
text: overrides.text ?? null,
|
|
14
|
+
desc: overrides.desc ?? null,
|
|
15
|
+
category: overrides.category ?? null,
|
|
16
|
+
group: overrides.group ?? null,
|
|
17
|
+
manual: overrides.manual ?? null,
|
|
18
|
+
deprecatedMessage: overrides.deprecatedMessage ?? null,
|
|
19
|
+
notifyChanges: overrides.notifyChanges ?? null,
|
|
20
|
+
permissions: overrides.permissions ?? null,
|
|
21
|
+
messages: overrides.messages ?? null,
|
|
22
|
+
arguments: overrides.arguments ?? [],
|
|
23
|
+
subcommands: overrides.subcommands ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function emptySub(overrides) {
|
|
27
|
+
return {
|
|
28
|
+
id: overrides.id ?? "todo::list",
|
|
29
|
+
cmd: overrides.cmd ?? "list",
|
|
30
|
+
aliases: overrides.aliases ?? [],
|
|
31
|
+
function: overrides.function ?? null,
|
|
32
|
+
desc: overrides.desc ?? null,
|
|
33
|
+
manual: overrides.manual ?? null,
|
|
34
|
+
arguments: overrides.arguments ?? [],
|
|
35
|
+
permissions: overrides.permissions ?? null,
|
|
36
|
+
messages: overrides.messages ?? null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
let addCalls = [];
|
|
40
|
+
let listCalls = [];
|
|
41
|
+
function registerTodoPlugin() {
|
|
42
|
+
addCalls = [];
|
|
43
|
+
listCalls = [];
|
|
44
|
+
const plugin = {
|
|
45
|
+
name: "todoPlugin",
|
|
46
|
+
status: "active",
|
|
47
|
+
run: null,
|
|
48
|
+
setup: null,
|
|
49
|
+
exports: {},
|
|
50
|
+
error: null,
|
|
51
|
+
guardOptions: {},
|
|
52
|
+
commands: {
|
|
53
|
+
addFn: {
|
|
54
|
+
cmd: "todo",
|
|
55
|
+
aliases: [],
|
|
56
|
+
handler: async (ctx, input) => {
|
|
57
|
+
addCalls.push({ ctx, input });
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
listFn: {
|
|
61
|
+
cmd: "list",
|
|
62
|
+
aliases: [],
|
|
63
|
+
handler: async (ctx, input) => {
|
|
64
|
+
listCalls.push({ ctx, input });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
crashFn: {
|
|
68
|
+
cmd: "crash",
|
|
69
|
+
aliases: [],
|
|
70
|
+
handler: async () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
pluginRegistry.set("todoPlugin", plugin);
|
|
77
|
+
}
|
|
78
|
+
function fakeCtx(overrides = {}) {
|
|
79
|
+
return {
|
|
80
|
+
chat: {
|
|
81
|
+
isGroup: overrides.isGroup ?? true,
|
|
82
|
+
id: "chat1@g.us",
|
|
83
|
+
isSenderAdmin: async () => true,
|
|
84
|
+
isBotAdmin: async () => true,
|
|
85
|
+
},
|
|
86
|
+
msg: {
|
|
87
|
+
sender: overrides.sender ?? "5511999999999@s.whatsapp.net",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function buildRegistry(specs) {
|
|
92
|
+
return buildCommandRegistry(specs, pluginRegistry);
|
|
93
|
+
}
|
|
94
|
+
describe("kernel/runCommand", () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
registerTodoPlugin();
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
pluginRegistry.delete("todoPlugin");
|
|
100
|
+
__setRegistryForTests(null);
|
|
101
|
+
});
|
|
102
|
+
test("resolveDispatch: kind none when registry is empty", () => {
|
|
103
|
+
__setRegistryForTests(buildCommandRegistry([], new Map()));
|
|
104
|
+
const { target } = resolveDispatch("todo", "");
|
|
105
|
+
assert.equal(target.kind, "none");
|
|
106
|
+
});
|
|
107
|
+
test("resolveDispatch: kind parent when no subcommands declared", () => {
|
|
108
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
109
|
+
const { target } = resolveDispatch("todo", "buy milk");
|
|
110
|
+
assert.equal(target.kind, "parent");
|
|
111
|
+
if (target.kind === "parent") {
|
|
112
|
+
assert.deepEqual(target.args, ["buy", "milk"]);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
test("resolveDispatch: kind sub when a declared subcommand token matches", () => {
|
|
116
|
+
const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
|
|
117
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
118
|
+
const { target } = resolveDispatch("todo", "list");
|
|
119
|
+
assert.equal(target.kind, "sub");
|
|
120
|
+
if (target.kind === "sub") {
|
|
121
|
+
assert.equal(target.sub.function, "listFn");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
test("resolveDispatch: unmatchedSubToken falls through to parent", () => {
|
|
125
|
+
const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
|
|
126
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
127
|
+
const { target, unmatchedSubToken } = resolveDispatch("todo", "wat now");
|
|
128
|
+
assert.equal(target.kind, "parent");
|
|
129
|
+
assert.equal(unmatchedSubToken, "wat");
|
|
130
|
+
});
|
|
131
|
+
test("runCommand: executes the parent handler and passes args through", async () => {
|
|
132
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
133
|
+
const resolution = resolveDispatch("todo", "buy milk");
|
|
134
|
+
const replies = [];
|
|
135
|
+
const result = await runCommand({
|
|
136
|
+
pluginName: "todoPlugin",
|
|
137
|
+
ctx: fakeCtx(),
|
|
138
|
+
resolution,
|
|
139
|
+
reply: { text: (t) => replies.push(t) },
|
|
140
|
+
});
|
|
141
|
+
assert.equal(result.status, "executed");
|
|
142
|
+
assert.equal(addCalls.length, 1);
|
|
143
|
+
assert.deepEqual(addCalls[0].input, { args: ["buy", "milk"], subcommand: undefined });
|
|
144
|
+
assert.equal(replies.length, 0);
|
|
145
|
+
});
|
|
146
|
+
test("runCommand: routes to the subcommand handler, not the parent's", async () => {
|
|
147
|
+
const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
|
|
148
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
149
|
+
const resolution = resolveDispatch("todo", "list");
|
|
150
|
+
const result = await runCommand({
|
|
151
|
+
pluginName: "todoPlugin",
|
|
152
|
+
ctx: fakeCtx(),
|
|
153
|
+
resolution,
|
|
154
|
+
reply: { text: () => { } },
|
|
155
|
+
});
|
|
156
|
+
assert.equal(result.status, "executed");
|
|
157
|
+
assert.equal(listCalls.length, 1);
|
|
158
|
+
assert.equal(addCalls.length, 0);
|
|
159
|
+
});
|
|
160
|
+
test("runCommand: unmatchedSubToken replies with a usage hint and does not dispatch", async () => {
|
|
161
|
+
const spec = emptySpec({ subcommands: [emptySub({ function: "listFn" })] });
|
|
162
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
163
|
+
const resolution = resolveDispatch("todo", "wat");
|
|
164
|
+
const replies = [];
|
|
165
|
+
const result = await runCommand({
|
|
166
|
+
pluginName: "todoPlugin",
|
|
167
|
+
ctx: fakeCtx(),
|
|
168
|
+
resolution,
|
|
169
|
+
reply: { text: (t) => replies.push(t) },
|
|
170
|
+
});
|
|
171
|
+
assert.equal(result.status, "unknown_sub");
|
|
172
|
+
assert.equal(addCalls.length, 0);
|
|
173
|
+
assert.equal(replies.length, 1);
|
|
174
|
+
});
|
|
175
|
+
test("runCommand: denies on scope mismatch and does not dispatch", async () => {
|
|
176
|
+
const spec = emptySpec({ permissions: { scope: "group" } });
|
|
177
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
178
|
+
const resolution = resolveDispatch("todo", "buy milk");
|
|
179
|
+
const result = await runCommand({
|
|
180
|
+
pluginName: "todoPlugin",
|
|
181
|
+
ctx: fakeCtx({ isGroup: false }),
|
|
182
|
+
resolution,
|
|
183
|
+
reply: { text: () => { } },
|
|
184
|
+
});
|
|
185
|
+
assert.equal(result.status, "permission_denied");
|
|
186
|
+
assert.equal(addCalls.length, 0);
|
|
187
|
+
});
|
|
188
|
+
test("runCommand: rejects when a required argument is missing", async () => {
|
|
189
|
+
const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
|
|
190
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
191
|
+
const resolution = resolveDispatch("todo", "");
|
|
192
|
+
const result = await runCommand({
|
|
193
|
+
pluginName: "todoPlugin",
|
|
194
|
+
ctx: fakeCtx(),
|
|
195
|
+
resolution,
|
|
196
|
+
reply: { text: () => { } },
|
|
197
|
+
});
|
|
198
|
+
assert.equal(result.status, "argument_missing");
|
|
199
|
+
assert.equal(addCalls.length, 0);
|
|
200
|
+
});
|
|
201
|
+
test("runCommand: re-throws on handler crash after firing the alert", async () => {
|
|
202
|
+
const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd", function: "crashFn" });
|
|
203
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
204
|
+
const resolution = resolveDispatch("crashcmd", "");
|
|
205
|
+
await assert.rejects(() => runCommand({
|
|
206
|
+
pluginName: "todoPlugin",
|
|
207
|
+
ctx: fakeCtx(),
|
|
208
|
+
resolution,
|
|
209
|
+
reply: { text: () => { } },
|
|
210
|
+
}), /boom/);
|
|
211
|
+
});
|
|
212
|
+
test("resolveDispatch: kind none for an unknown invocation", () => {
|
|
213
|
+
__setRegistryForTests(buildRegistry([emptySpec({})]));
|
|
214
|
+
const { target } = resolveDispatch("nope", "");
|
|
215
|
+
assert.equal(target.kind, "none");
|
|
216
|
+
});
|
|
217
|
+
test("renderUsage: builds a usage line from declared arguments", () => {
|
|
218
|
+
const spec = emptySpec({
|
|
219
|
+
arguments: [
|
|
220
|
+
{ name: "item", type: "quoted_text", required: true },
|
|
221
|
+
{ name: "priority", type: "choice", choices: ["low", "high"], required: false },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
__setRegistryForTests(buildRegistry([spec]));
|
|
225
|
+
const { target } = resolveDispatch("todo", "");
|
|
226
|
+
const usage = renderUsage(target);
|
|
227
|
+
assert.match(usage, /^!todo /);
|
|
228
|
+
assert.match(usage, /"<text>"/);
|
|
229
|
+
assert.match(usage, /\[--priority=<low\|high>\]/);
|
|
230
|
+
});
|
|
231
|
+
test("renderUsage: empty string for a none target", () => {
|
|
232
|
+
const usage = renderUsage({ kind: "none" });
|
|
233
|
+
assert.equal(usage, "");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -24,11 +24,6 @@ import { waitForSendSlot } from "./sendGuard.js";
|
|
|
24
24
|
import { getDriverManager } from "./driverManager.js";
|
|
25
25
|
import { fireAlert } from "./alerts.js";
|
|
26
26
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
27
|
-
/**
|
|
28
|
-
* Thrown by sendWithFallback when the message could not be delivered
|
|
29
|
-
* through any available driver. `reason` distinguishes between
|
|
30
|
-
* "primary failed and no secondary was available" vs "both failed".
|
|
31
|
-
*/
|
|
32
27
|
export class SendFailedError extends Error {
|
|
33
28
|
jid;
|
|
34
29
|
driver;
|
|
@@ -42,44 +37,25 @@ export class SendFailedError extends Error {
|
|
|
42
37
|
}
|
|
43
38
|
}
|
|
44
39
|
/**
|
|
45
|
-
* Try the active driver
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* how long to wait for confirmation before giving up on a given attempt.
|
|
40
|
+
* Try the active driver and verify the send. Honors `drivers.fallbackCooldownMs`
|
|
41
|
+
* (though with only one driver, degradation doesn't skip attempts) and uses
|
|
42
|
+
* `drivers.verifyWindowMs` to decide how long to wait for confirmation.
|
|
49
43
|
*
|
|
50
44
|
* Resolves with the SentMessageRef of the driver that actually delivered
|
|
51
|
-
* the message. Rejects with SendFailedError
|
|
52
|
-
*
|
|
53
|
-
* active one and it wasn't ready).
|
|
45
|
+
* the message. Rejects with SendFailedError with reason "no_fallback" if the
|
|
46
|
+
* send could not be confirmed.
|
|
54
47
|
*/
|
|
55
48
|
export async function sendWithFallback(jid, text, opts = {}) {
|
|
56
49
|
const dm = getDriverManager();
|
|
57
50
|
const drivers = CONFIG.drivers;
|
|
58
51
|
const primary = dm.active();
|
|
59
52
|
const primaryKey = primary.name;
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// send_failed_both_drivers — observability stays consistent across
|
|
64
|
-
// the degraded and fresh-primary paths.
|
|
53
|
+
// Mark as not degraded before attempting (degradation only lasts for cooldown period)
|
|
54
|
+
// With only one driver, we don't actually skip attempts when degraded - we just track
|
|
55
|
+
// that the last attempt failed so we can alert appropriately
|
|
65
56
|
if (dm.isDegraded(primaryKey)) {
|
|
66
|
-
|
|
67
|
-
if (secondary && secondary.isReady()) {
|
|
68
|
-
try {
|
|
69
|
-
return await sendVia(secondary, jid, text, opts, drivers.verifyWindowMs, /*skipGuard=*/ false);
|
|
70
|
-
}
|
|
71
|
-
catch (err) {
|
|
72
|
-
fireAlert("send_failed_both_drivers", { jid, primary: primaryKey, secondary: secondary.name, error: String(err) });
|
|
73
|
-
throw err;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
logger.warn({ jid, primary: primaryKey }, "send skipped primary (degraded) and no fallback ready");
|
|
77
|
-
fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
|
|
78
|
-
throw new SendFailedError(jid, primaryKey, "no_fallback");
|
|
57
|
+
logger.debug({ driver: primaryKey }, "driver in degradation period but attempting send anyway (single driver mode)");
|
|
79
58
|
}
|
|
80
|
-
// Normal path: try primary, verify, fall back if verification fails.
|
|
81
|
-
// waitForSendSlot is the same throttle the rest of the senders use
|
|
82
|
-
// (fallback must respect rate-limit too).
|
|
83
59
|
await waitForSendSlot(jid, { cooldown: true, jitter: true });
|
|
84
60
|
let primaryRef = null;
|
|
85
61
|
let primarySendFailed = false;
|
|
@@ -89,28 +65,23 @@ export async function sendWithFallback(jid, text, opts = {}) {
|
|
|
89
65
|
catch (err) {
|
|
90
66
|
primarySendFailed = true;
|
|
91
67
|
logger.warn({ driver: primaryKey, jid, error: String(err) }, "send threw on primary");
|
|
68
|
+
dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
|
|
69
|
+
fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
|
|
70
|
+
throw new SendFailedError(jid, primaryKey, "no_fallback");
|
|
92
71
|
}
|
|
93
72
|
if (!primarySendFailed) {
|
|
94
73
|
if (await verifyDelivery(primary, jid, primaryRef, drivers.verifyWindowMs)) {
|
|
74
|
+
// Successful send - clear any degradation state
|
|
75
|
+
dm.clearDegraded(primaryKey);
|
|
95
76
|
return primaryRef;
|
|
96
77
|
}
|
|
97
78
|
logger.warn({ driver: primaryKey, jid, messageId: primaryRef.id }, "send not confirmed by primary");
|
|
98
|
-
|
|
99
|
-
dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
|
|
100
|
-
const secondary = pickSecondary(dm, primaryKey);
|
|
101
|
-
if (!secondary || !secondary.isReady()) {
|
|
79
|
+
dm.markDegraded(primaryKey, drivers.fallbackCooldownMs);
|
|
102
80
|
fireAlert("send_failed_no_fallback", { jid, primary: primaryKey });
|
|
103
81
|
throw new SendFailedError(jid, primaryKey, "no_fallback");
|
|
104
82
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
logger.info({ driver: secondary.name, jid, messageId: fallbackRef.id, reason: primarySendFailed ? "send threw" : "primary verification failed" }, "message sent via fallback");
|
|
108
|
-
return fallbackRef;
|
|
109
|
-
}
|
|
110
|
-
catch (err) {
|
|
111
|
-
fireAlert("send_failed_both_drivers", { jid, primary: primaryKey, secondary: secondary.name, error: String(err) });
|
|
112
|
-
throw err;
|
|
113
|
-
}
|
|
83
|
+
// Should not reach here
|
|
84
|
+
throw new SendFailedError(jid, primaryKey, "no_fallback");
|
|
114
85
|
}
|
|
115
86
|
/**
|
|
116
87
|
* Send through a specific driver and verify the result. Throws if the
|
|
@@ -178,6 +149,6 @@ async function historyContains(driver, jid, ref) {
|
|
|
178
149
|
return history.some(m => m.fromMe && m.id === ref.id);
|
|
179
150
|
}
|
|
180
151
|
function pickSecondary(dm, primaryKey) {
|
|
181
|
-
|
|
182
|
-
return dm.get(
|
|
152
|
+
// Currently Baileys is the only supported driver; returns undefined unless a test registers a secondary
|
|
153
|
+
return dm.get(primaryKey === "baileys" ? "secondary" : "baileys");
|
|
183
154
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import test, { describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { sendWithFallback, SendFailedError } from "#kernel/sendFallbackGuard.js";
|
|
4
|
+
import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
|
|
5
|
+
function createMockDriver(name, ready = true, failsSend = false, failsVerify = false) {
|
|
6
|
+
const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
isReady: () => ready,
|
|
10
|
+
sendText: async (jid, text) => {
|
|
11
|
+
if (failsSend)
|
|
12
|
+
throw new Error(`${name} sendText failed`);
|
|
13
|
+
return mockRef(`msg_${name}`);
|
|
14
|
+
},
|
|
15
|
+
getHistory: async () => {
|
|
16
|
+
if (failsVerify)
|
|
17
|
+
return [];
|
|
18
|
+
return [{ id: `msg_${name}`, fromMe: true }];
|
|
19
|
+
},
|
|
20
|
+
connect: async () => { },
|
|
21
|
+
disconnect: async () => { },
|
|
22
|
+
me: () => ({ id: "123@c.us" }),
|
|
23
|
+
sendImage: async () => mockRef("image"),
|
|
24
|
+
sendVideo: async () => mockRef("video"),
|
|
25
|
+
sendAudio: async () => mockRef("audio"),
|
|
26
|
+
sendDocument: async () => mockRef("doc"),
|
|
27
|
+
sendSticker: async () => mockRef("sticker"),
|
|
28
|
+
sendLocation: async () => mockRef("loc"),
|
|
29
|
+
sendContact: async () => mockRef("contact"),
|
|
30
|
+
sendReaction: async () => { },
|
|
31
|
+
sendPoll: async () => mockRef("poll"),
|
|
32
|
+
react: async () => { },
|
|
33
|
+
deleteMessage: async () => { },
|
|
34
|
+
editMessage: async () => { },
|
|
35
|
+
sendPresenceUpdate: async () => { },
|
|
36
|
+
readMessages: async () => { },
|
|
37
|
+
onWhatsApp: async () => null,
|
|
38
|
+
getBusinessProfile: async () => null,
|
|
39
|
+
profilePictureUrl: async () => null,
|
|
40
|
+
fetchStatus: async () => null,
|
|
41
|
+
updateBlockStatus: async () => { },
|
|
42
|
+
addOrEditContact: async () => { },
|
|
43
|
+
removeContact: async () => { },
|
|
44
|
+
groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
|
|
45
|
+
groupParticipantsUpdate: async () => [],
|
|
46
|
+
groupUpdateSubject: async () => { },
|
|
47
|
+
groupUpdateDescription: async () => { },
|
|
48
|
+
groupInviteCode: async () => "",
|
|
49
|
+
groupRevokeInvite: async () => "",
|
|
50
|
+
updateProfilePicture: async () => { },
|
|
51
|
+
updateProfileName: async () => { },
|
|
52
|
+
updateProfileStatus: async () => { },
|
|
53
|
+
downloadMedia: async () => null,
|
|
54
|
+
on: () => () => { },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
describe("kernel/sendFallbackGuard", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
_resetDriverManagerForTests();
|
|
60
|
+
});
|
|
61
|
+
test("delivers text via primary driver when healthy", async () => {
|
|
62
|
+
const dm = getDriverManager();
|
|
63
|
+
const primary = createMockDriver("baileys");
|
|
64
|
+
dm.register(primary, { isPrimary: true });
|
|
65
|
+
const ref = await sendWithFallback("5511999999999@c.us", "hello");
|
|
66
|
+
assert.equal(ref.id, "msg_baileys");
|
|
67
|
+
});
|
|
68
|
+
test("throws SendFailedError with no_fallback when send fails", async () => {
|
|
69
|
+
const dm = getDriverManager();
|
|
70
|
+
const primaryFailing = createMockDriver("baileys", true, true);
|
|
71
|
+
dm.register(primaryFailing, { isPrimary: true });
|
|
72
|
+
await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "no fallback"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
|
|
73
|
+
});
|
|
74
|
+
test("throws SendFailedError with no_fallback when verification fails", async () => {
|
|
75
|
+
const dm = getDriverManager();
|
|
76
|
+
const primaryFailing = createMockDriver("baileys", true, false, true);
|
|
77
|
+
dm.register(primaryFailing, { isPrimary: true });
|
|
78
|
+
await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "verify fail"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
|
|
79
|
+
});
|
|
80
|
+
});
|
package/dist/kernel/sendGuard.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 4. Chat-concurrency gate — caps how many different chats the bot can be
|
|
11
11
|
* actively answering at the same time
|
|
12
12
|
* 5. Edit throttle — jittered minimum gap + cap on edits per
|
|
13
|
-
* message
|
|
14
|
-
*
|
|
13
|
+
* message. Only active at SECURITY_LEVEL
|
|
14
|
+
* "high"; low/medium leave edit timing to the
|
|
15
|
+
* caller.
|
|
15
16
|
*
|
|
16
17
|
* All of the above scale with SECURITY_LEVEL ("low" | "medium" | "high").
|
|
17
18
|
* Higher levels are slower and more conservative — lower risk of WhatsApp's
|
|
@@ -28,8 +29,6 @@ const PROFILES = {
|
|
|
28
29
|
chatCooldownMs: 100,
|
|
29
30
|
jitterMs: { min: 30, max: 120 },
|
|
30
31
|
concurrency: Infinity,
|
|
31
|
-
editIntervalMs: { min: 800, max: 2000 },
|
|
32
|
-
maxEditsPerMessage: 20,
|
|
33
32
|
typingMaxMs: 2000,
|
|
34
33
|
},
|
|
35
34
|
medium: {
|
|
@@ -37,8 +36,6 @@ const PROFILES = {
|
|
|
37
36
|
chatCooldownMs: 150,
|
|
38
37
|
jitterMs: { min: 50, max: 200 },
|
|
39
38
|
concurrency: 2,
|
|
40
|
-
editIntervalMs: { min: 1200, max: 3000 },
|
|
41
|
-
maxEditsPerMessage: 12,
|
|
42
39
|
typingMaxMs: 4000,
|
|
43
40
|
},
|
|
44
41
|
high: {
|
|
@@ -46,9 +43,11 @@ const PROFILES = {
|
|
|
46
43
|
chatCooldownMs: 400,
|
|
47
44
|
jitterMs: { min: 150, max: 500 },
|
|
48
45
|
concurrency: 1,
|
|
49
|
-
editIntervalMs: { min: 2000, max: 5000 },
|
|
50
|
-
maxEditsPerMessage: 6,
|
|
51
46
|
typingMaxMs: 8000,
|
|
47
|
+
editThrottle: {
|
|
48
|
+
minGapMs: { min: 800, max: 2000 },
|
|
49
|
+
maxEditsPerMessage: 5,
|
|
50
|
+
},
|
|
52
51
|
},
|
|
53
52
|
};
|
|
54
53
|
function currentProfile() {
|
|
@@ -147,40 +146,6 @@ export async function acquireChatSlot(jid) {
|
|
|
147
146
|
});
|
|
148
147
|
});
|
|
149
148
|
}
|
|
150
|
-
const editState = new Map();
|
|
151
|
-
const EDIT_STATE_STALE_MS = 10 * 60 * 1000;
|
|
152
|
-
function cleanupEditState(now) {
|
|
153
|
-
for (const [id, s] of editState) {
|
|
154
|
-
if (now - s.lastEditAt > EDIT_STATE_STALE_MS)
|
|
155
|
-
editState.delete(id);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Waits for a safe edit slot for `messageId`, applying a jittered minimum
|
|
160
|
-
* gap since its last edit. Returns false once the message has hit its
|
|
161
|
-
* per-level edit cap — callers should skip the edit silently in that case.
|
|
162
|
-
* @param {string} messageId
|
|
163
|
-
* @returns {Promise<boolean>} true if the edit may proceed
|
|
164
|
-
*/
|
|
165
|
-
export async function waitForEditSlot(messageId) {
|
|
166
|
-
const now = Date.now();
|
|
167
|
-
cleanupEditState(now);
|
|
168
|
-
const profile = currentProfile();
|
|
169
|
-
const s = editState.get(messageId) ?? { lastEditAt: 0, count: 0 };
|
|
170
|
-
if (s.count >= profile.maxEditsPerMessage) {
|
|
171
|
-
editState.set(messageId, s);
|
|
172
|
-
logger.debug(`[sendGuard] edit cap reached for ${messageId}`);
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
const minGap = randomBetween(profile.editIntervalMs);
|
|
176
|
-
const wait = s.lastEditAt + minGap - Date.now();
|
|
177
|
-
if (wait > 0)
|
|
178
|
-
await sleep(wait);
|
|
179
|
-
s.lastEditAt = Date.now();
|
|
180
|
-
s.count += 1;
|
|
181
|
-
editState.set(messageId, s);
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
149
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
185
150
|
/**
|
|
186
151
|
* Wait for a safe send slot: global rate → per-chat cooldown → jitter.
|
|
@@ -208,6 +173,37 @@ export async function waitForSendSlot(jid, { cooldown = true, jitter = true } =
|
|
|
208
173
|
await sleep(randomBetween(currentProfile().jitterMs));
|
|
209
174
|
recordSend(jid);
|
|
210
175
|
}
|
|
176
|
+
// ── Edit throttle ─────────────────────────────────────────────────────────────
|
|
177
|
+
// Only enforced when the active profile defines `editThrottle` (currently
|
|
178
|
+
// just "high"). low/medium always allow immediately.
|
|
179
|
+
const editState = new Map();
|
|
180
|
+
/**
|
|
181
|
+
* Wait for a safe edit slot for `messageId`, then record the edit.
|
|
182
|
+
* Returns `false` if the per-message edit cap has been reached — the
|
|
183
|
+
* caller should drop the edit instead of sending it.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} messageId
|
|
186
|
+
* @returns {Promise<boolean>} whether the edit is allowed to proceed
|
|
187
|
+
*/
|
|
188
|
+
export async function waitForEditSlot(messageId) {
|
|
189
|
+
const throttle = currentProfile().editThrottle;
|
|
190
|
+
if (!throttle)
|
|
191
|
+
return true;
|
|
192
|
+
const state = editState.get(messageId) ?? { count: 0, lastEditAt: 0 };
|
|
193
|
+
if (state.count >= throttle.maxEditsPerMessage) {
|
|
194
|
+
logger.debug(`[sendGuard] edit cap (${throttle.maxEditsPerMessage}) reached for message ${messageId} — dropping edit`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const gap = randomBetween(throttle.minGapMs);
|
|
198
|
+
const elapsed = Date.now() - state.lastEditAt;
|
|
199
|
+
if (state.lastEditAt > 0 && elapsed < gap) {
|
|
200
|
+
await sleep(gap - elapsed);
|
|
201
|
+
}
|
|
202
|
+
state.count++;
|
|
203
|
+
state.lastEditAt = Date.now();
|
|
204
|
+
editState.set(messageId, state);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
211
207
|
/**
|
|
212
208
|
* Show a presence indicator for `ms` milliseconds, then clear it.
|
|
213
209
|
* Best-effort — errors are swallowed.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import test, { describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { CONFIG } from "#config";
|
|
4
|
+
import { typingDuration, mediaDuration, acquireChatSlot, simulateState, waitForSendSlot } from "#kernel/sendGuard.js";
|
|
5
|
+
describe("kernel/sendGuard", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
CONFIG.SECURITY_LEVEL = "medium";
|
|
8
|
+
});
|
|
9
|
+
describe("typingDuration", () => {
|
|
10
|
+
test("returns 0 for empty or invalid text", () => {
|
|
11
|
+
assert.equal(typingDuration(""), 0);
|
|
12
|
+
assert.equal(typingDuration(null), 0);
|
|
13
|
+
});
|
|
14
|
+
test("calculates duration based on CPS and caps at profile typingMaxMs", () => {
|
|
15
|
+
CONFIG.SECURITY_LEVEL = "medium"; // typingMaxMs = 4000
|
|
16
|
+
// 90 chars at 90 CPS = 1000 ms
|
|
17
|
+
const text90 = "a".repeat(90);
|
|
18
|
+
assert.equal(typingDuration(text90), 1000);
|
|
19
|
+
// 900 chars at 90 CPS = 10000 ms -> capped at 4000 ms
|
|
20
|
+
const text900 = "a".repeat(900);
|
|
21
|
+
assert.equal(typingDuration(text900), 4000);
|
|
22
|
+
});
|
|
23
|
+
test("respects SECURITY_LEVEL profile caps", () => {
|
|
24
|
+
const text900 = "a".repeat(900);
|
|
25
|
+
CONFIG.SECURITY_LEVEL = "low"; // cap = 2000
|
|
26
|
+
assert.equal(typingDuration(text900), 2000);
|
|
27
|
+
CONFIG.SECURITY_LEVEL = "high"; // cap = 8000
|
|
28
|
+
assert.equal(typingDuration(text900), 8000);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("mediaDuration", () => {
|
|
32
|
+
test("returns base jitter range when no caption is provided", () => {
|
|
33
|
+
const duration = mediaDuration();
|
|
34
|
+
assert.ok(duration >= 400 && duration <= 1000);
|
|
35
|
+
});
|
|
36
|
+
test("adds typing duration when caption is provided", () => {
|
|
37
|
+
CONFIG.SECURITY_LEVEL = "medium";
|
|
38
|
+
const text90 = "a".repeat(90); // 1000ms typing
|
|
39
|
+
const duration = mediaDuration(text90);
|
|
40
|
+
assert.ok(duration >= 1400 && duration <= 2000);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("acquireChatSlot (concurrency gate)", () => {
|
|
44
|
+
test("allows up to profile concurrency before blocking", async () => {
|
|
45
|
+
CONFIG.SECURITY_LEVEL = "high"; // concurrency = 1
|
|
46
|
+
const release1 = await acquireChatSlot("chat1");
|
|
47
|
+
let slot2Acquired = false;
|
|
48
|
+
const promise2 = acquireChatSlot("chat2").then(rel => {
|
|
49
|
+
slot2Acquired = true;
|
|
50
|
+
return rel;
|
|
51
|
+
});
|
|
52
|
+
// Give microtask tick to verify slot2 is waiting
|
|
53
|
+
await new Promise(r => setImmediate(r));
|
|
54
|
+
assert.equal(slot2Acquired, false);
|
|
55
|
+
// Release slot 1 allows slot 2 to proceed
|
|
56
|
+
release1();
|
|
57
|
+
const release2 = await promise2;
|
|
58
|
+
assert.equal(slot2Acquired, true);
|
|
59
|
+
release2();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("simulateState", () => {
|
|
63
|
+
test("sends presence update composing/recording, waits, then sends paused", async (t) => {
|
|
64
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
65
|
+
const updates = [];
|
|
66
|
+
const mockContract = {
|
|
67
|
+
sendPresenceUpdate: async (state, jid) => {
|
|
68
|
+
updates.push({ state, jid });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const simPromise = simulateState(mockContract, "123@c.us", 1000, "typing");
|
|
72
|
+
// simulateState awaits the first presence update before scheduling its
|
|
73
|
+
// timeout, so let that continuation run before advancing mock time.
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
t.mock.timers.tick(1000);
|
|
76
|
+
await simPromise;
|
|
77
|
+
assert.deepEqual(updates, [
|
|
78
|
+
{ state: "composing", jid: "123@c.us" },
|
|
79
|
+
{ state: "paused", jid: "123@c.us" }
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
test("handles non-fatal contract errors gracefully", async () => {
|
|
83
|
+
const failingContract = {
|
|
84
|
+
sendPresenceUpdate: async () => {
|
|
85
|
+
throw new Error("Network error");
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// Should not throw exception
|
|
89
|
+
await assert.doesNotReject(async () => {
|
|
90
|
+
await simulateState(failingContract, "123@c.us", 100, "typing");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("waitForSendSlot", () => {
|
|
95
|
+
test("completes send throttle without errors", async (t) => {
|
|
96
|
+
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
|
97
|
+
const sendPromise = waitForSendSlot("123@c.us", { cooldown: false, jitter: false });
|
|
98
|
+
t.mock.timers.tick(500);
|
|
99
|
+
await sendPromise;
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -14,9 +14,10 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import { mkdirSync } from "fs";
|
|
16
16
|
import { CONFIG_DIR } from "#config";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
17
|
+
const DB_PATH = process.env.NODE_ENV === "test" ? ":memory:" : path.join(CONFIG_DIR, "settings.db");
|
|
18
|
+
if (DB_PATH !== ":memory:") {
|
|
19
|
+
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
20
|
+
}
|
|
20
21
|
const db = new DatabaseSync(DB_PATH);
|
|
21
22
|
db.exec("PRAGMA journal_mode = WAL");
|
|
22
23
|
db.exec("PRAGMA foreign_keys = ON");
|