@manybot/manybot 5.7.0 → 5.9.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 (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  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/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,497 @@
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
+ import { STOP_CHAIN } from "#kernel/commandsConfig.js";
7
+ import { buildSettingsApi } from "#kernel/settingsDb.js";
8
+ import { CONFIG } from "#config";
9
+ // Pin the "no override" global language independently of whatever
10
+ // ~/.manybot/manybot.toml happens to say on the machine running the
11
+ // suite — CONFIG.LANGUAGE is read from the real config dir unless
12
+ // MANYBOT_CONFIG_DIR is set, and i18n's `currentLang` locks in on the
13
+ // first t()/tFor() call, so this must run before any test does.
14
+ CONFIG.LANGUAGE = "en";
15
+ function emptySpec(overrides) {
16
+ return {
17
+ id: overrides.id ?? "todo::add",
18
+ cmd: overrides.cmd ?? "todo",
19
+ aliases: overrides.aliases ?? [],
20
+ plugin: overrides.plugin ?? "todoPlugin",
21
+ functions: overrides.functions ?? ["addFn"],
22
+ loading: overrides.loading ?? null,
23
+ text: overrides.text ?? null,
24
+ desc: overrides.desc ?? null,
25
+ category: overrides.category ?? null,
26
+ group: overrides.group ?? null,
27
+ manual: overrides.manual ?? null,
28
+ deprecatedMessage: overrides.deprecatedMessage ?? null,
29
+ notifyChanges: overrides.notifyChanges ?? null,
30
+ permissions: overrides.permissions ?? null,
31
+ messages: overrides.messages ?? null,
32
+ arguments: overrides.arguments ?? [],
33
+ subcommands: overrides.subcommands ?? [],
34
+ };
35
+ }
36
+ function emptySub(overrides) {
37
+ return {
38
+ id: overrides.id ?? "todo::list",
39
+ cmd: overrides.cmd ?? "list",
40
+ aliases: overrides.aliases ?? [],
41
+ functions: overrides.functions ?? null,
42
+ loading: overrides.loading ?? null,
43
+ desc: overrides.desc ?? null,
44
+ manual: overrides.manual ?? null,
45
+ arguments: overrides.arguments ?? [],
46
+ permissions: overrides.permissions ?? null,
47
+ messages: overrides.messages ?? null,
48
+ };
49
+ }
50
+ let addCalls = [];
51
+ let listCalls = [];
52
+ function registerTodoPlugin() {
53
+ addCalls = [];
54
+ listCalls = [];
55
+ const plugin = {
56
+ name: "todoPlugin",
57
+ status: "active",
58
+ run: null,
59
+ setup: null,
60
+ exports: {},
61
+ error: null,
62
+ guardOptions: {},
63
+ commands: {
64
+ addFn: {
65
+ cmd: "todo",
66
+ aliases: [],
67
+ handler: async (ctx, input) => {
68
+ addCalls.push({ ctx, input });
69
+ },
70
+ },
71
+ listFn: {
72
+ cmd: "list",
73
+ aliases: [],
74
+ handler: async (ctx, input) => {
75
+ listCalls.push({ ctx, input });
76
+ },
77
+ },
78
+ crashFn: {
79
+ cmd: "crash",
80
+ aliases: [],
81
+ handler: async () => {
82
+ throw new Error("boom");
83
+ },
84
+ },
85
+ gateFn: {
86
+ cmd: "gate",
87
+ aliases: [],
88
+ handler: async () => STOP_CHAIN,
89
+ },
90
+ },
91
+ };
92
+ pluginRegistry.set("todoPlugin", plugin);
93
+ }
94
+ function fakeCtx(overrides = {}) {
95
+ return {
96
+ chat: {
97
+ isGroup: overrides.isGroup ?? true,
98
+ id: "chat1@g.us",
99
+ isSenderAdmin: async () => true,
100
+ isBotAdmin: async () => true,
101
+ },
102
+ msg: {
103
+ sender: overrides.sender ?? "5511999999999@s.whatsapp.net",
104
+ },
105
+ };
106
+ }
107
+ function buildRegistry(specs) {
108
+ return buildCommandRegistry(specs, pluginRegistry);
109
+ }
110
+ describe("kernel/runCommand", () => {
111
+ beforeEach(() => {
112
+ registerTodoPlugin();
113
+ });
114
+ afterEach(() => {
115
+ pluginRegistry.delete("todoPlugin");
116
+ __setRegistryForTests(null);
117
+ });
118
+ test("resolveDispatch: kind none when registry is empty", () => {
119
+ __setRegistryForTests(buildCommandRegistry([], new Map()));
120
+ const { target } = resolveDispatch("todo", "");
121
+ assert.equal(target.kind, "none");
122
+ });
123
+ test("resolveDispatch: kind parent when no subcommands declared", () => {
124
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
125
+ const { target } = resolveDispatch("todo", "buy milk");
126
+ assert.equal(target.kind, "parent");
127
+ if (target.kind === "parent") {
128
+ assert.deepEqual(target.args, ["buy", "milk"]);
129
+ }
130
+ });
131
+ test("resolveDispatch: kind sub when a declared subcommand token matches", () => {
132
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
133
+ __setRegistryForTests(buildRegistry([spec]));
134
+ const { target } = resolveDispatch("todo", "list");
135
+ assert.equal(target.kind, "sub");
136
+ if (target.kind === "sub") {
137
+ assert.equal(target.sub.function, "listFn");
138
+ }
139
+ });
140
+ test("resolveDispatch: unmatchedSubToken falls through to parent", () => {
141
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
142
+ __setRegistryForTests(buildRegistry([spec]));
143
+ const { target, unmatchedSubToken } = resolveDispatch("todo", "wat now");
144
+ assert.equal(target.kind, "parent");
145
+ assert.equal(unmatchedSubToken, "wat");
146
+ });
147
+ test("runCommand: executes the parent handler and passes args through", async () => {
148
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
149
+ const resolution = resolveDispatch("todo", "buy milk");
150
+ const replies = [];
151
+ const result = await runCommand({
152
+ pluginName: "todoPlugin",
153
+ ctx: fakeCtx(),
154
+ resolution,
155
+ reply: { text: (t) => replies.push(t) },
156
+ });
157
+ assert.equal(result.status, "executed");
158
+ assert.equal(addCalls.length, 1);
159
+ assert.deepEqual(addCalls[0].input, { args: ["buy", "milk"], subcommand: undefined });
160
+ assert.equal(replies.length, 0);
161
+ });
162
+ test("runCommand: routes to the subcommand handler, not the parent's", async () => {
163
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
164
+ __setRegistryForTests(buildRegistry([spec]));
165
+ const resolution = resolveDispatch("todo", "list");
166
+ const result = await runCommand({
167
+ pluginName: "todoPlugin",
168
+ ctx: fakeCtx(),
169
+ resolution,
170
+ reply: { text: () => { } },
171
+ });
172
+ assert.equal(result.status, "executed");
173
+ assert.equal(listCalls.length, 1);
174
+ assert.equal(addCalls.length, 0);
175
+ });
176
+ test("runCommand: unmatchedSubToken replies with a usage hint and does not dispatch", async () => {
177
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
178
+ __setRegistryForTests(buildRegistry([spec]));
179
+ const resolution = resolveDispatch("todo", "wat");
180
+ const replies = [];
181
+ const result = await runCommand({
182
+ pluginName: "todoPlugin",
183
+ ctx: fakeCtx(),
184
+ resolution,
185
+ reply: { text: (t) => replies.push(t) },
186
+ });
187
+ assert.equal(result.status, "unknown_sub");
188
+ assert.equal(addCalls.length, 0);
189
+ assert.equal(replies.length, 1);
190
+ });
191
+ test("runCommand: denies on scope mismatch and does not dispatch", async () => {
192
+ const spec = emptySpec({ permissions: { scope: "group" } });
193
+ __setRegistryForTests(buildRegistry([spec]));
194
+ const resolution = resolveDispatch("todo", "buy milk");
195
+ const result = await runCommand({
196
+ pluginName: "todoPlugin",
197
+ ctx: fakeCtx({ isGroup: false }),
198
+ resolution,
199
+ reply: { text: () => { } },
200
+ });
201
+ assert.equal(result.status, "permission_denied");
202
+ assert.equal(addCalls.length, 0);
203
+ });
204
+ test("runCommand: rejects when a required argument is missing", async () => {
205
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
206
+ __setRegistryForTests(buildRegistry([spec]));
207
+ const resolution = resolveDispatch("todo", "");
208
+ const result = await runCommand({
209
+ pluginName: "todoPlugin",
210
+ ctx: fakeCtx(),
211
+ resolution,
212
+ reply: { text: () => { } },
213
+ });
214
+ assert.equal(result.status, "argument_missing");
215
+ assert.equal(addCalls.length, 0);
216
+ });
217
+ test("runCommand: re-throws on handler crash after firing the alert", async () => {
218
+ const spec = emptySpec({ id: "todo::crash", cmd: "crashcmd", functions: ["crashFn"] });
219
+ __setRegistryForTests(buildRegistry([spec]));
220
+ const resolution = resolveDispatch("crashcmd", "");
221
+ await assert.rejects(() => runCommand({
222
+ pluginName: "todoPlugin",
223
+ ctx: fakeCtx(),
224
+ resolution,
225
+ reply: { text: () => { } },
226
+ }), /boom/);
227
+ });
228
+ test("resolveDispatch: kind none for an unknown invocation", () => {
229
+ __setRegistryForTests(buildRegistry([emptySpec({})]));
230
+ const { target } = resolveDispatch("nope", "");
231
+ assert.equal(target.kind, "none");
232
+ });
233
+ test("renderUsage: builds a usage line from declared arguments", () => {
234
+ const spec = emptySpec({
235
+ arguments: [
236
+ { name: "item", type: "quoted_text", required: true },
237
+ { name: "priority", type: "choice", choices: ["low", "high"], required: false },
238
+ ],
239
+ });
240
+ __setRegistryForTests(buildRegistry([spec]));
241
+ const { target } = resolveDispatch("todo", "");
242
+ const usage = renderUsage(target);
243
+ assert.match(usage, /^!todo /);
244
+ assert.match(usage, /"<text>"/);
245
+ assert.match(usage, /\[--priority=<low\|high>\]/);
246
+ });
247
+ test("renderUsage: empty string for a none target", () => {
248
+ const usage = renderUsage({ kind: "none" });
249
+ assert.equal(usage, "");
250
+ });
251
+ test("renderUsage: accepts a prefix override", () => {
252
+ const spec = emptySpec({
253
+ arguments: [{ name: "item", type: "quoted_text", required: true }],
254
+ });
255
+ __setRegistryForTests(buildRegistry([spec]));
256
+ const { target } = resolveDispatch("todo", "");
257
+ const usage = renderUsage(target, "#");
258
+ assert.match(usage, /^#todo /);
259
+ });
260
+ describe("per-chat overrides (prefix / language via !config)", () => {
261
+ const overrideChatId = "5511988887777@c.us"; // already-normalized form
262
+ beforeEach(() => {
263
+ buildSettingsApi("core", overrideChatId).deleteAll();
264
+ });
265
+ afterEach(() => {
266
+ buildSettingsApi("core", overrideChatId).deleteAll();
267
+ });
268
+ test("missing-argument usage message uses the chat's saved prefix override", async () => {
269
+ buildSettingsApi("core", overrideChatId).set("chat_prefix", "#");
270
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
271
+ __setRegistryForTests(buildRegistry([spec]));
272
+ const resolution = resolveDispatch("todo", "");
273
+ const replies = [];
274
+ const result = await runCommand({
275
+ pluginName: "todoPlugin",
276
+ ctx: fakeCtx(),
277
+ resolution,
278
+ reply: { text: (t) => replies.push(t) },
279
+ chatId: overrideChatId,
280
+ });
281
+ assert.equal(result.status, "argument_missing");
282
+ assert.match(replies[0], /#todo/, "usage line should use the chat's saved prefix, not the global one");
283
+ });
284
+ test("missing-argument usage message falls back to the global prefix when no override is set", async () => {
285
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
286
+ __setRegistryForTests(buildRegistry([spec]));
287
+ const resolution = resolveDispatch("todo", "");
288
+ const replies = [];
289
+ await runCommand({
290
+ pluginName: "todoPlugin",
291
+ ctx: fakeCtx(),
292
+ resolution,
293
+ reply: { text: (t) => replies.push(t) },
294
+ chatId: overrideChatId,
295
+ });
296
+ assert.match(replies[0], /!todo/);
297
+ });
298
+ test("unknown-subcommand message uses the chat's saved language override", async () => {
299
+ buildSettingsApi("core", overrideChatId).set("chat_locale", "pt");
300
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
301
+ __setRegistryForTests(buildRegistry([spec]));
302
+ const resolution = resolveDispatch("todo", "wat");
303
+ const replies = [];
304
+ await runCommand({
305
+ pluginName: "todoPlugin",
306
+ ctx: fakeCtx(),
307
+ resolution,
308
+ reply: { text: (t) => replies.push(t) },
309
+ chatId: overrideChatId,
310
+ });
311
+ assert.match(replies[0], /Subcomando "wat" desconhecido/);
312
+ });
313
+ test("unknown-subcommand message falls back to the global language when no override is set", async () => {
314
+ const spec = emptySpec({ subcommands: [emptySub({ functions: ["listFn"] })] });
315
+ __setRegistryForTests(buildRegistry([spec]));
316
+ const resolution = resolveDispatch("todo", "wat");
317
+ const replies = [];
318
+ await runCommand({
319
+ pluginName: "todoPlugin",
320
+ ctx: fakeCtx(),
321
+ resolution,
322
+ reply: { text: (t) => replies.push(t) },
323
+ chatId: overrideChatId,
324
+ });
325
+ assert.match(replies[0], /Unknown subcommand "wat"/);
326
+ });
327
+ test("omitting chatId behaves exactly like an unset override (backward compatible)", async () => {
328
+ const spec = emptySpec({ arguments: [{ name: "item", type: "quoted_text", required: true }] });
329
+ __setRegistryForTests(buildRegistry([spec]));
330
+ const resolution = resolveDispatch("todo", "");
331
+ const replies = [];
332
+ const result = await runCommand({
333
+ pluginName: "todoPlugin",
334
+ ctx: fakeCtx(),
335
+ resolution,
336
+ reply: { text: (t) => replies.push(t) },
337
+ });
338
+ assert.equal(result.status, "argument_missing");
339
+ assert.match(replies[0], /!todo/);
340
+ });
341
+ });
342
+ describe("functions: chain + STOP_CHAIN", () => {
343
+ test("runs every function in the chain in declared order", async () => {
344
+ let second = false;
345
+ let third = false;
346
+ const plugin = {
347
+ name: "chainPlugin",
348
+ status: "active",
349
+ run: null,
350
+ setup: null,
351
+ exports: {},
352
+ error: null,
353
+ guardOptions: {},
354
+ commands: {
355
+ firstFn: { cmd: "todo", aliases: [], handler: async () => { second = true; } },
356
+ secondFn: { cmd: "todo", aliases: [], handler: async () => {
357
+ assert.equal(second, true, "second handler ran before first finished");
358
+ third = true;
359
+ } },
360
+ thirdFn: { cmd: "todo", aliases: [], handler: async () => {
361
+ assert.equal(third, true, "third handler ran before second finished");
362
+ } },
363
+ },
364
+ };
365
+ pluginRegistry.set("chainPlugin", plugin);
366
+ const specs = [
367
+ {
368
+ id: "chainPlugin::firstFn",
369
+ cmd: "chain",
370
+ aliases: [],
371
+ plugin: "chainPlugin",
372
+ functions: ["firstFn", "secondFn", "thirdFn"],
373
+ loading: null,
374
+ text: null,
375
+ desc: null,
376
+ category: null,
377
+ group: null,
378
+ manual: null,
379
+ deprecatedMessage: null,
380
+ notifyChanges: null,
381
+ permissions: null,
382
+ messages: null,
383
+ arguments: [],
384
+ subcommands: [],
385
+ },
386
+ ];
387
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
388
+ const resolution = resolveDispatch("chain", "");
389
+ const result = await runCommand({
390
+ pluginName: "chainPlugin",
391
+ ctx: fakeCtx(),
392
+ resolution,
393
+ reply: { text: () => { } },
394
+ });
395
+ assert.equal(result.status, "executed");
396
+ assert.equal(third, true);
397
+ pluginRegistry.delete("chainPlugin");
398
+ });
399
+ test("STOP_CHAIN short-circuits the rest of the chain", async () => {
400
+ let secondRan = false;
401
+ const plugin = {
402
+ name: "stopPlugin",
403
+ status: "active",
404
+ run: null,
405
+ setup: null,
406
+ exports: {},
407
+ error: null,
408
+ guardOptions: {},
409
+ commands: {
410
+ firstFn: { cmd: "stop", aliases: [], handler: async () => STOP_CHAIN },
411
+ secondFn: { cmd: "stop", aliases: [], handler: async () => { secondRan = true; } },
412
+ },
413
+ };
414
+ pluginRegistry.set("stopPlugin", plugin);
415
+ const specs = [
416
+ {
417
+ id: "stopPlugin::firstFn",
418
+ cmd: "stop",
419
+ aliases: [],
420
+ plugin: "stopPlugin",
421
+ functions: ["firstFn", "secondFn"],
422
+ loading: null,
423
+ text: null,
424
+ desc: null,
425
+ category: null,
426
+ group: null,
427
+ manual: null,
428
+ deprecatedMessage: null,
429
+ notifyChanges: null,
430
+ permissions: null,
431
+ messages: null,
432
+ arguments: [],
433
+ subcommands: [],
434
+ },
435
+ ];
436
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
437
+ const resolution = resolveDispatch("stop", "");
438
+ const result = await runCommand({
439
+ pluginName: "stopPlugin",
440
+ ctx: fakeCtx(),
441
+ resolution,
442
+ reply: { text: () => { } },
443
+ });
444
+ assert.equal(result.status, "executed");
445
+ assert.equal(secondRan, false);
446
+ pluginRegistry.delete("stopPlugin");
447
+ });
448
+ test("chain throw propagates and stops the chain", async () => {
449
+ let secondRan = false;
450
+ const plugin = {
451
+ name: "throwPlugin",
452
+ status: "active",
453
+ run: null,
454
+ setup: null,
455
+ exports: {},
456
+ error: null,
457
+ guardOptions: {},
458
+ commands: {
459
+ firstFn: { cmd: "throw", aliases: [], handler: async () => { throw new Error("mid"); } },
460
+ secondFn: { cmd: "throw", aliases: [], handler: async () => { secondRan = true; } },
461
+ },
462
+ };
463
+ pluginRegistry.set("throwPlugin", plugin);
464
+ const specs = [
465
+ {
466
+ id: "throwPlugin::firstFn",
467
+ cmd: "throw",
468
+ aliases: [],
469
+ plugin: "throwPlugin",
470
+ functions: ["firstFn", "secondFn"],
471
+ loading: null,
472
+ text: null,
473
+ desc: null,
474
+ category: null,
475
+ group: null,
476
+ manual: null,
477
+ deprecatedMessage: null,
478
+ notifyChanges: null,
479
+ permissions: null,
480
+ messages: null,
481
+ arguments: [],
482
+ subcommands: [],
483
+ },
484
+ ];
485
+ __setRegistryForTests(buildCommandRegistry(specs, pluginRegistry));
486
+ const resolution = resolveDispatch("throw", "");
487
+ await assert.rejects(() => runCommand({
488
+ pluginName: "throwPlugin",
489
+ ctx: fakeCtx(),
490
+ resolution,
491
+ reply: { text: () => { } },
492
+ }), /mid/);
493
+ assert.equal(secondRan, false);
494
+ pluginRegistry.delete("throwPlugin");
495
+ });
496
+ });
497
+ });
@@ -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
  }