@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.
Files changed (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/drivers/baileys/adapter.js +58 -7
  9. package/dist/drivers/baileys/api/index.js +172 -24
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +74 -17
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. 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, then the other one if the active one failed to
46
- * confirm the send. Honors `drivers.fallbackCooldownMs` (skip the active
47
- * if it's currently degraded) and uses `drivers.verifyWindowMs` to decide
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 if neither driver confirmed
52
- * the send (or if the only driver that could have been tried was the
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
- // if the primary is in cooldown, skip straight to the secondary
61
- // (don't repeat the same failing call message after message). Same
62
- // try/catch as the normal path so a failing secondary here also fires
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
- const secondary = pickSecondary(dm, primaryKey);
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
- try {
106
- const fallbackRef = await sendVia(secondary, jid, text, opts, drivers.verifyWindowMs, /*skipGuard=*/ true);
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
- const other = primaryKey === "baileys" ? "whatsmeow" : "baileys";
182
- return dm.get(other);
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
+ });
@@ -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, so things like loading animations
14
- * don't edit on a fixed, bot-like cadence
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
- // ── DB init ───────────────────────────────────────────────────────────────────
18
- const DB_PATH = path.join(CONFIG_DIR, "settings.db");
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");