@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.
- package/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- 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/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- 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 +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -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 +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -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 +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -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 +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- 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,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
|
|
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
|
}
|