@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,236 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, beforeEach, describe, test } from "node:test";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-commands-config-"));
|
|
7
|
+
process.env.MANYBOT_CONFIG_DIR = configDir;
|
|
8
|
+
const { loadCommandsConfig, parseLocalizedString, resolveFileRef } = await import("#kernel/commandsConfig.js");
|
|
9
|
+
const commandsFile = path.join(configDir, "commands.yaml");
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
await fs.rm(commandsFile, { force: true });
|
|
12
|
+
});
|
|
13
|
+
after(async () => {
|
|
14
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
describe("kernel/commandsConfig", () => {
|
|
17
|
+
test("parses localized strings and preserves only usable values", () => {
|
|
18
|
+
assert.equal(parseLocalizedString(" hello "), "hello");
|
|
19
|
+
assert.equal(parseLocalizedString(" "), null);
|
|
20
|
+
assert.deepEqual(parseLocalizedString({ en: " Hello ", pt: " Olá ", invalid: 3 }), {
|
|
21
|
+
en: "Hello",
|
|
22
|
+
pt: "Olá",
|
|
23
|
+
});
|
|
24
|
+
assert.equal(parseLocalizedString([]), null);
|
|
25
|
+
});
|
|
26
|
+
test("returns null when commands.yaml does not exist", async () => {
|
|
27
|
+
assert.equal(await loadCommandsConfig(), null);
|
|
28
|
+
});
|
|
29
|
+
test("uses defaults for an empty YAML document", async () => {
|
|
30
|
+
await fs.writeFile(commandsFile, "null\n", "utf8");
|
|
31
|
+
const config = await loadCommandsConfig();
|
|
32
|
+
assert.ok(config);
|
|
33
|
+
assert.equal(config.defaults.notifyChanges, true);
|
|
34
|
+
assert.equal(config.defaults.notifyPeriodDays, 7);
|
|
35
|
+
assert.deepEqual(config.menu.aliases, ["help", "man", "menu", "bot", "?"]);
|
|
36
|
+
assert.deepEqual(config.specs, []);
|
|
37
|
+
});
|
|
38
|
+
test("rejects malformed and non-object YAML roots", async () => {
|
|
39
|
+
await fs.writeFile(commandsFile, "commands: [unterminated", "utf8");
|
|
40
|
+
assert.equal(await loadCommandsConfig(), null);
|
|
41
|
+
await fs.writeFile(commandsFile, "- not\n- a mapping\n", "utf8");
|
|
42
|
+
assert.equal(await loadCommandsConfig(), null);
|
|
43
|
+
});
|
|
44
|
+
test("loads defaults, menu, categories, manuals, file references and command permissions", async () => {
|
|
45
|
+
await fs.writeFile(path.join(configDir, "reply.txt"), "Reply from file", "utf8");
|
|
46
|
+
await fs.writeFile(path.join(configDir, "manual-pt.txt"), "Manual em português", "utf8");
|
|
47
|
+
await fs.writeFile(commandsFile, `
|
|
48
|
+
defaults:
|
|
49
|
+
notifyChanges: false
|
|
50
|
+
notifyPeriodDays: 12.8
|
|
51
|
+
notifyMessage: " Command changed "
|
|
52
|
+
permissions:
|
|
53
|
+
admin: true
|
|
54
|
+
scope: GROUP
|
|
55
|
+
cooldownSeconds: 4
|
|
56
|
+
whitelist:
|
|
57
|
+
groups: [ " group@g.us ", 1 ]
|
|
58
|
+
users: [ " user@c.us " ]
|
|
59
|
+
messages:
|
|
60
|
+
cooldown: " Wait "
|
|
61
|
+
menu:
|
|
62
|
+
title: { en: " Commands ", pt: " Comandos " }
|
|
63
|
+
intro: " Intro "
|
|
64
|
+
footer: " Footer "
|
|
65
|
+
aliases: [ help, " ? ", 1 ]
|
|
66
|
+
notFoundFallback: true
|
|
67
|
+
categories:
|
|
68
|
+
fun:
|
|
69
|
+
label: { en: " Fun " }
|
|
70
|
+
order: 2
|
|
71
|
+
uncategorized: {}
|
|
72
|
+
manuals:
|
|
73
|
+
greeting: "file: manual-pt.txt"
|
|
74
|
+
hello:
|
|
75
|
+
cmd: " hello "
|
|
76
|
+
aliases: [ hi, " oi ", 3 ]
|
|
77
|
+
plugin: " sample "
|
|
78
|
+
function: " greet "
|
|
79
|
+
text: "file: reply.txt"
|
|
80
|
+
desc: { en: " Say hello ", pt: " Diga oi " }
|
|
81
|
+
category: " fun "
|
|
82
|
+
manual: { pt: "file: manual-pt.txt", en: " Plain manual " }
|
|
83
|
+
deprecatedMessage: " Old command "
|
|
84
|
+
notifyChanges: false
|
|
85
|
+
permissions:
|
|
86
|
+
botAdmin: true
|
|
87
|
+
owner: false
|
|
88
|
+
scope: dm
|
|
89
|
+
cooldownSeconds: 0
|
|
90
|
+
blacklist:
|
|
91
|
+
users: [ blocked@c.us ]
|
|
92
|
+
messages:
|
|
93
|
+
ownerOnly: " Owners only "
|
|
94
|
+
invalid: "not a command"
|
|
95
|
+
missingCmd:
|
|
96
|
+
aliases: [ no ]
|
|
97
|
+
`, "utf8");
|
|
98
|
+
const config = await loadCommandsConfig();
|
|
99
|
+
assert.ok(config);
|
|
100
|
+
assert.deepEqual(config.defaults, {
|
|
101
|
+
notifyChanges: false,
|
|
102
|
+
notifyPeriodDays: 12,
|
|
103
|
+
notifyMessage: "Command changed",
|
|
104
|
+
permissions: {
|
|
105
|
+
admin: true,
|
|
106
|
+
botAdmin: undefined,
|
|
107
|
+
owner: undefined,
|
|
108
|
+
scope: "group",
|
|
109
|
+
cooldownSeconds: 4,
|
|
110
|
+
whitelist: { groups: ["group@g.us"], users: ["user@c.us"] },
|
|
111
|
+
blacklist: undefined,
|
|
112
|
+
},
|
|
113
|
+
messages: { botNotAdmin: undefined, senderNotAdmin: undefined, ownerOnly: undefined, wrongScope: undefined, cooldown: "Wait" },
|
|
114
|
+
});
|
|
115
|
+
assert.deepEqual(config.menu, {
|
|
116
|
+
title: { en: "Commands", pt: "Comandos" },
|
|
117
|
+
intro: "Intro",
|
|
118
|
+
footer: "Footer",
|
|
119
|
+
cmd: "menu",
|
|
120
|
+
aliases: ["help", "?"],
|
|
121
|
+
notFoundFallback: true,
|
|
122
|
+
welcomeMessage: null,
|
|
123
|
+
welcomeWindowDays: 3,
|
|
124
|
+
pageSize: 15,
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(config.categories, {
|
|
127
|
+
fun: { label: { en: "Fun" }, order: 2, scope: null, hiddenInScope: null },
|
|
128
|
+
uncategorized: { label: "uncategorized", order: 999, scope: null, hiddenInScope: null },
|
|
129
|
+
});
|
|
130
|
+
assert.deepEqual(config.manuals, { greeting: "Manual em português" });
|
|
131
|
+
assert.equal(config.specs.length, 1);
|
|
132
|
+
assert.deepEqual(config.specs[0], {
|
|
133
|
+
id: "hello",
|
|
134
|
+
cmd: "hello",
|
|
135
|
+
aliases: ["hi", "oi"],
|
|
136
|
+
plugin: "sample",
|
|
137
|
+
function: "greet",
|
|
138
|
+
text: "Reply from file",
|
|
139
|
+
desc: { en: "Say hello", pt: "Diga oi" },
|
|
140
|
+
category: "fun",
|
|
141
|
+
group: null,
|
|
142
|
+
manual: { pt: "Manual em português", en: "Plain manual" },
|
|
143
|
+
deprecatedMessage: "Old command",
|
|
144
|
+
notifyChanges: false,
|
|
145
|
+
permissions: {
|
|
146
|
+
admin: undefined,
|
|
147
|
+
botAdmin: true,
|
|
148
|
+
owner: false,
|
|
149
|
+
scope: "dm",
|
|
150
|
+
cooldownSeconds: 0,
|
|
151
|
+
whitelist: undefined,
|
|
152
|
+
blacklist: { groups: undefined, users: ["blocked@c.us"] },
|
|
153
|
+
},
|
|
154
|
+
messages: { botNotAdmin: undefined, senderNotAdmin: undefined, ownerOnly: "Owners only", wrongScope: undefined, cooldown: undefined },
|
|
155
|
+
arguments: [],
|
|
156
|
+
subcommands: [],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
test("keeps an unreadable file reference as the original text", async () => {
|
|
160
|
+
assert.equal(await resolveFileRef("file: missing.txt"), "file: missing.txt");
|
|
161
|
+
assert.deepEqual(await resolveFileRef({ en: "file: missing.txt", pt: "Texto" }), {
|
|
162
|
+
en: "file: missing.txt",
|
|
163
|
+
pt: "Texto",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
test("import: merges top-level sections from auxiliary files", async () => {
|
|
167
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
168
|
+
menu:
|
|
169
|
+
title: "Imported Menu"
|
|
170
|
+
aliases: ["ajuda"]
|
|
171
|
+
`, "utf8");
|
|
172
|
+
await fs.writeFile(path.join(configDir, "manual.yaml"), `
|
|
173
|
+
manuals:
|
|
174
|
+
hello: "Imported manual"
|
|
175
|
+
`, "utf8");
|
|
176
|
+
await fs.writeFile(commandsFile, `
|
|
177
|
+
import:
|
|
178
|
+
- menu.yaml
|
|
179
|
+
- manual.yaml
|
|
180
|
+
hello:
|
|
181
|
+
cmd: hello
|
|
182
|
+
plugin: sample
|
|
183
|
+
function: greet
|
|
184
|
+
`, "utf8");
|
|
185
|
+
const config = await loadCommandsConfig();
|
|
186
|
+
assert.ok(config);
|
|
187
|
+
assert.equal(config.menu.title, "Imported Menu");
|
|
188
|
+
assert.deepEqual(config.menu.aliases, ["ajuda"]);
|
|
189
|
+
assert.deepEqual(config.manuals, { hello: "Imported manual" });
|
|
190
|
+
assert.equal(config.specs.length, 1);
|
|
191
|
+
assert.equal(config.specs[0].id, "hello");
|
|
192
|
+
});
|
|
193
|
+
test("import: accepts a single path (not wrapped in a list)", async () => {
|
|
194
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
195
|
+
menu:
|
|
196
|
+
title: "Solo Import"
|
|
197
|
+
`, "utf8");
|
|
198
|
+
await fs.writeFile(commandsFile, `
|
|
199
|
+
import: menu.yaml
|
|
200
|
+
`, "utf8");
|
|
201
|
+
const config = await loadCommandsConfig();
|
|
202
|
+
assert.ok(config);
|
|
203
|
+
assert.equal(config.menu.title, "Solo Import");
|
|
204
|
+
});
|
|
205
|
+
test("import: a key already owned by the main file or an earlier import is kept, not overwritten", async () => {
|
|
206
|
+
await fs.writeFile(path.join(configDir, "menu.yaml"), `
|
|
207
|
+
menu:
|
|
208
|
+
title: "Should be ignored"
|
|
209
|
+
`, "utf8");
|
|
210
|
+
await fs.writeFile(commandsFile, `
|
|
211
|
+
import:
|
|
212
|
+
- menu.yaml
|
|
213
|
+
menu:
|
|
214
|
+
title: "Main file wins"
|
|
215
|
+
`, "utf8");
|
|
216
|
+
const config = await loadCommandsConfig();
|
|
217
|
+
assert.ok(config);
|
|
218
|
+
assert.equal(config.menu.title, "Main file wins");
|
|
219
|
+
});
|
|
220
|
+
test("import: a missing or malformed import file is skipped without failing the whole load", async () => {
|
|
221
|
+
await fs.writeFile(path.join(configDir, "broken.yaml"), "not: [a, valid\n", "utf8");
|
|
222
|
+
await fs.writeFile(commandsFile, `
|
|
223
|
+
import:
|
|
224
|
+
- does-not-exist.yaml
|
|
225
|
+
- broken.yaml
|
|
226
|
+
hello:
|
|
227
|
+
cmd: hello
|
|
228
|
+
plugin: sample
|
|
229
|
+
function: greet
|
|
230
|
+
`, "utf8");
|
|
231
|
+
const config = await loadCommandsConfig();
|
|
232
|
+
assert.ok(config);
|
|
233
|
+
assert.equal(config.specs.length, 1);
|
|
234
|
+
assert.equal(config.specs[0].id, "hello");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { trackIncomingForContactSave } from "#kernel/contactAutoSave.js";
|
|
4
|
+
function createMockContract() {
|
|
5
|
+
const saved = [];
|
|
6
|
+
return {
|
|
7
|
+
savedContacts: saved,
|
|
8
|
+
addOrEditContact: async (jid, info) => {
|
|
9
|
+
saved.push({ jid, name: info.fullName || "" });
|
|
10
|
+
},
|
|
11
|
+
name: "baileys",
|
|
12
|
+
isReady: () => true,
|
|
13
|
+
connect: async () => { },
|
|
14
|
+
disconnect: async () => { },
|
|
15
|
+
me: () => ({ id: "123@c.us" }),
|
|
16
|
+
sendText: async () => ({ id: "msg", chatId: "123@c.us", timestamp: Date.now() }),
|
|
17
|
+
sendImage: async () => ({ id: "img", chatId: "123@c.us", timestamp: Date.now() }),
|
|
18
|
+
sendVideo: async () => ({ id: "vid", chatId: "123@c.us", timestamp: Date.now() }),
|
|
19
|
+
sendAudio: async () => ({ id: "aud", chatId: "123@c.us", timestamp: Date.now() }),
|
|
20
|
+
sendDocument: async () => ({ id: "doc", chatId: "123@c.us", timestamp: Date.now() }),
|
|
21
|
+
sendSticker: async () => ({ id: "stk", chatId: "123@c.us", timestamp: Date.now() }),
|
|
22
|
+
sendLocation: async () => ({ id: "loc", chatId: "123@c.us", timestamp: Date.now() }),
|
|
23
|
+
sendContact: async () => ({ id: "cnt", chatId: "123@c.us", timestamp: Date.now() }),
|
|
24
|
+
sendReaction: async () => { },
|
|
25
|
+
sendPoll: async () => ({ id: "pll", chatId: "123@c.us", timestamp: Date.now() }),
|
|
26
|
+
react: async () => { },
|
|
27
|
+
deleteMessage: async () => { },
|
|
28
|
+
editMessage: async () => { },
|
|
29
|
+
sendPresenceUpdate: async () => { },
|
|
30
|
+
readMessages: async () => { },
|
|
31
|
+
onWhatsApp: async () => null,
|
|
32
|
+
getBusinessProfile: async () => null,
|
|
33
|
+
profilePictureUrl: async () => null,
|
|
34
|
+
fetchStatus: async () => null,
|
|
35
|
+
updateBlockStatus: async () => { },
|
|
36
|
+
removeContact: async () => { },
|
|
37
|
+
groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
|
|
38
|
+
groupParticipantsUpdate: async () => [],
|
|
39
|
+
groupUpdateSubject: async () => { },
|
|
40
|
+
groupUpdateDescription: async () => { },
|
|
41
|
+
groupInviteCode: async () => "",
|
|
42
|
+
groupRevokeInvite: async () => "",
|
|
43
|
+
updateProfilePicture: async () => { },
|
|
44
|
+
updateProfileName: async () => { },
|
|
45
|
+
updateProfileStatus: async () => { },
|
|
46
|
+
downloadMedia: async () => null,
|
|
47
|
+
on: () => () => { },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
describe("kernel/contactAutoSave", () => {
|
|
51
|
+
test("accumulates DM messages and triggers addOrEditContact when target reached", async () => {
|
|
52
|
+
const contract = createMockContract();
|
|
53
|
+
const jid = "5511999991111@c.us";
|
|
54
|
+
const msg = {
|
|
55
|
+
id: "1",
|
|
56
|
+
chatId: jid,
|
|
57
|
+
fromMe: false,
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
text: "hello",
|
|
60
|
+
pushName: "Alice",
|
|
61
|
+
raw: {},
|
|
62
|
+
};
|
|
63
|
+
// Send up to 6 DM messages (max target range is 3-6)
|
|
64
|
+
for (let i = 0; i < 6; i++) {
|
|
65
|
+
await trackIncomingForContactSave(contract, msg, jid, false, false);
|
|
66
|
+
}
|
|
67
|
+
assert.ok(contract.savedContacts.length > 0);
|
|
68
|
+
assert.equal(contract.savedContacts[0].name, "Alice");
|
|
69
|
+
});
|
|
70
|
+
test("ignores silent group messages (triggeredBot = false)", async () => {
|
|
71
|
+
const contract = createMockContract();
|
|
72
|
+
const jid = "5511999992222@c.us";
|
|
73
|
+
const msg = {
|
|
74
|
+
id: "2",
|
|
75
|
+
chatId: "group1@g.us",
|
|
76
|
+
fromMe: false,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
text: "just chatting",
|
|
79
|
+
pushName: "Bob",
|
|
80
|
+
raw: {},
|
|
81
|
+
};
|
|
82
|
+
for (let i = 0; i < 10; i++) {
|
|
83
|
+
await trackIncomingForContactSave(contract, msg, jid, true, false);
|
|
84
|
+
}
|
|
85
|
+
assert.equal(contract.savedContacts.length, 0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -11,12 +11,7 @@
|
|
|
11
11
|
* globalSock in pluginLoader.ts. Only main.ts is expected to call
|
|
12
12
|
* register(); everywhere else reads through active() / get() / isDegraded.
|
|
13
13
|
*
|
|
14
|
-
* Shutdown order in shutdown() is reverse-registration
|
|
15
|
-
* that was added later (e.g. whatsmeow) is disconnected before the
|
|
16
|
-
* primary one (typically Baileys). Re-registering the same name
|
|
17
|
-
* overwrites the previous instance — the old driver is NOT disconnected
|
|
18
|
-
* automatically, callers must disconnect it first if they want it torn
|
|
19
|
-
* down.
|
|
14
|
+
* Shutdown order in shutdown() is reverse-registration.
|
|
20
15
|
*
|
|
21
16
|
* See the interface and cooldown semantics.
|
|
22
17
|
*/
|
|
@@ -69,6 +64,15 @@ class DriverManager {
|
|
|
69
64
|
markDegraded(name, durationMs) {
|
|
70
65
|
this.degradedUntil.set(name, Date.now() + durationMs);
|
|
71
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Drop the degradation entry for `name` so the next `isDegraded()`
|
|
69
|
+
* check returns false. Used after a successful send to clear the
|
|
70
|
+
* cooldown that the most recent failed send had set, without waiting
|
|
71
|
+
* for the timer to expire.
|
|
72
|
+
*/
|
|
73
|
+
clearDegraded(name) {
|
|
74
|
+
this.degradedUntil.delete(name);
|
|
75
|
+
}
|
|
72
76
|
/**
|
|
73
77
|
* Promote a different driver to active. Used by tests / hot-swap;
|
|
74
78
|
* the production sendFallbackGuard never calls this — fallback uses
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import test, { describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
|
|
4
|
+
function createFakeDriver(name, ready = true) {
|
|
5
|
+
const state = { disconnected: false };
|
|
6
|
+
const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
isReady: () => ready,
|
|
10
|
+
disconnect: async () => {
|
|
11
|
+
state.disconnected = true;
|
|
12
|
+
},
|
|
13
|
+
get disconnected() {
|
|
14
|
+
return state.disconnected;
|
|
15
|
+
},
|
|
16
|
+
connect: async () => { },
|
|
17
|
+
me: () => ({ id: "123@c.us" }),
|
|
18
|
+
sendText: async () => mockRef("msg1"),
|
|
19
|
+
sendImage: async () => mockRef("msg2"),
|
|
20
|
+
sendVideo: async () => mockRef("msg3"),
|
|
21
|
+
sendAudio: async () => mockRef("msg4"),
|
|
22
|
+
sendSticker: async () => mockRef("msg5"),
|
|
23
|
+
sendDocument: async () => mockRef("msg6"),
|
|
24
|
+
sendLocation: async () => mockRef("msg7"),
|
|
25
|
+
sendContact: async () => mockRef("msg8"),
|
|
26
|
+
sendReaction: async () => { },
|
|
27
|
+
sendPoll: async () => mockRef("msg9"),
|
|
28
|
+
react: async () => { },
|
|
29
|
+
deleteMessage: async () => { },
|
|
30
|
+
editMessage: async () => { },
|
|
31
|
+
sendPresenceUpdate: async () => { },
|
|
32
|
+
readMessages: async () => { },
|
|
33
|
+
onWhatsApp: async () => null,
|
|
34
|
+
getBusinessProfile: async () => null,
|
|
35
|
+
profilePictureUrl: async () => null,
|
|
36
|
+
fetchStatus: async () => null,
|
|
37
|
+
updateBlockStatus: async () => { },
|
|
38
|
+
addOrEditContact: async () => { },
|
|
39
|
+
removeContact: async () => { },
|
|
40
|
+
groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
|
|
41
|
+
groupParticipantsUpdate: async () => [],
|
|
42
|
+
groupUpdateSubject: async () => { },
|
|
43
|
+
groupUpdateDescription: async () => { },
|
|
44
|
+
groupInviteCode: async () => "",
|
|
45
|
+
groupRevokeInvite: async () => "",
|
|
46
|
+
updateProfilePicture: async () => { },
|
|
47
|
+
updateProfileName: async () => { },
|
|
48
|
+
updateProfileStatus: async () => { },
|
|
49
|
+
downloadMedia: async () => null,
|
|
50
|
+
getContact: async () => null,
|
|
51
|
+
getProfilePictureUrl: async () => null,
|
|
52
|
+
on: () => () => { },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
describe("kernel/driverManager", () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
_resetDriverManagerForTests();
|
|
58
|
+
});
|
|
59
|
+
test("registers driver and returns active instance", () => {
|
|
60
|
+
const dm = getDriverManager();
|
|
61
|
+
const baileys = createFakeDriver("baileys");
|
|
62
|
+
dm.register(baileys, { isPrimary: true });
|
|
63
|
+
assert.equal(dm.activeName_(), "baileys");
|
|
64
|
+
assert.equal(dm.active(), baileys);
|
|
65
|
+
assert.equal(dm.get("baileys"), baileys);
|
|
66
|
+
assert.equal(dm.isReady("baileys"), true);
|
|
67
|
+
});
|
|
68
|
+
test("throws if active() called with no drivers registered", () => {
|
|
69
|
+
const dm = getDriverManager();
|
|
70
|
+
assert.throws(() => dm.active(), /no active driver registered/);
|
|
71
|
+
});
|
|
72
|
+
test("tracks degradation with expiration", async (t) => {
|
|
73
|
+
t.mock.timers.enable({ apis: ["Date"] });
|
|
74
|
+
const dm = getDriverManager();
|
|
75
|
+
const baileys = createFakeDriver("baileys");
|
|
76
|
+
dm.register(baileys, { isPrimary: true });
|
|
77
|
+
assert.equal(dm.isDegraded("baileys"), false);
|
|
78
|
+
dm.markDegraded("baileys", 1000);
|
|
79
|
+
assert.equal(dm.isDegraded("baileys"), true);
|
|
80
|
+
t.mock.timers.tick(1001);
|
|
81
|
+
assert.equal(dm.isDegraded("baileys"), false);
|
|
82
|
+
});
|
|
83
|
+
test("shutdown disconnects all drivers", async () => {
|
|
84
|
+
const dm = getDriverManager();
|
|
85
|
+
const baileys = createFakeDriver("baileys");
|
|
86
|
+
dm.register(baileys, { isPrimary: true });
|
|
87
|
+
await dm.shutdown();
|
|
88
|
+
assert.equal(baileys.disconnected, true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/integrationMode.ts
|
|
3
|
+
*
|
|
4
|
+
* Toggles the "integration" mode that gates the WhatsApp integration
|
|
5
|
+
* test suite. In integration mode the bot loads a private plugin
|
|
6
|
+
* (`__manybot_integration__`) whose job is to drive real-WhatsApp
|
|
7
|
+
* operations from the test harness, and refuse to act in any chat
|
|
8
|
+
* other than the configured `TEST_CHAT`.
|
|
9
|
+
*
|
|
10
|
+
* Activation requires BOTH signals (mirrors `testConfig`):
|
|
11
|
+
* - `MANYBOT_RUN_WHATSAPP_TESTS=1` (explicit opt-in)
|
|
12
|
+
* - a configured `TEST_CHAT` (env or manybot.toml)
|
|
13
|
+
*
|
|
14
|
+
* Production code never imports this module in its hot path. The
|
|
15
|
+
* integration plugin loader is the only consumer; the production bot
|
|
16
|
+
* does not load the integration plugin and is not affected by the
|
|
17
|
+
* opt-in flag.
|
|
18
|
+
*
|
|
19
|
+
* The reserved plugin name uses a `__manybot_*__` double-underscore
|
|
20
|
+
* pattern so it cannot collide with a real plugin (the loader's
|
|
21
|
+
* normal `~/.manybot/plugins/<name>/` path does not create a
|
|
22
|
+
* directory starting with `__` on user machines, and the test setup
|
|
23
|
+
* installs it into a separate, non-user-controlled location).
|
|
24
|
+
*/
|
|
25
|
+
import path from "path";
|
|
26
|
+
import { getTestConfig, requireTestConfig } from "#kernel/testConfig.js";
|
|
27
|
+
import { logger } from "#logger";
|
|
28
|
+
/**
|
|
29
|
+
* Reserved plugin name. The leading and trailing `__` are
|
|
30
|
+
* deliberate: this name is documented as reserved by the runtime,
|
|
31
|
+
* so no real plugin directory should ever be created with it.
|
|
32
|
+
* Plugins loaded from `~/.manybot/plugins/` with names that match
|
|
33
|
+
* this pattern are NOT auto-loaded by `loadPlugins()`; the
|
|
34
|
+
* integration harness has to ask for them explicitly.
|
|
35
|
+
*/
|
|
36
|
+
export const INTEGRATION_PLUGIN_NAME = "__manybot_integration__";
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path to the integration plugin's source directory inside
|
|
39
|
+
* the project. The plugin ships with the repo (not with the user's
|
|
40
|
+
* `~/.manybot/plugins/`) and is loaded directly from `src/`.
|
|
41
|
+
*
|
|
42
|
+
* Resolved relative to this module so the path survives the build
|
|
43
|
+
* (the plugin source is part of the published `src/` tree today;
|
|
44
|
+
* if/when the integration plugin gets moved into `dist/`, only
|
|
45
|
+
* this single constant needs to change).
|
|
46
|
+
*/
|
|
47
|
+
const INTEGRATION_PLUGIN_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "plugins", INTEGRATION_PLUGIN_NAME);
|
|
48
|
+
/** True only when the operator has set the explicit opt-in flag. */
|
|
49
|
+
export function isIntegrationOptIn() {
|
|
50
|
+
return process.env.MANYBOT_RUN_WHATSAPP_TESTS === "1";
|
|
51
|
+
}
|
|
52
|
+
export async function getIntegrationModeStatus() {
|
|
53
|
+
const cfg = await getTestConfig();
|
|
54
|
+
if (!isIntegrationOptIn()) {
|
|
55
|
+
return {
|
|
56
|
+
ready: false,
|
|
57
|
+
reason: `MANYBOT_RUN_WHATSAPP_TESTS=1 is required to enable integration mode`,
|
|
58
|
+
chat: cfg.chat,
|
|
59
|
+
source: cfg.source,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (cfg.chat === null) {
|
|
63
|
+
return {
|
|
64
|
+
ready: false,
|
|
65
|
+
reason: cfg.skipReason,
|
|
66
|
+
chat: null,
|
|
67
|
+
source: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ready: true, reason: null, chat: cfg.chat, source: cfg.source };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Hard version: throws if integration mode is not fully ready. Use
|
|
74
|
+
* this at the entry point of every test that touches the real bot.
|
|
75
|
+
*/
|
|
76
|
+
export async function requireIntegrationMode() {
|
|
77
|
+
if (!isIntegrationOptIn()) {
|
|
78
|
+
throw new Error(`[integrationMode] cannot run: MANYBOT_RUN_WHATSAPP_TESTS=1 is required. ` +
|
|
79
|
+
`Set it explicitly to opt in to the real-WhatsApp test suite.`);
|
|
80
|
+
}
|
|
81
|
+
const cfg = await requireTestConfig();
|
|
82
|
+
logger.info(`[integrationMode] enabled — chat=${cfg.chat} source=${cfg.source}`);
|
|
83
|
+
return { chat: cfg.chat, source: cfg.source };
|
|
84
|
+
}
|
|
85
|
+
/** Absolute path of the integration plugin's source directory. */
|
|
86
|
+
export function getIntegrationPluginDir() {
|
|
87
|
+
return INTEGRATION_PLUGIN_DIR;
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import test, { describe, before, after, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-integration-mode-"));
|
|
7
|
+
process.env.MANYBOT_CONFIG_DIR = configDir;
|
|
8
|
+
const { getIntegrationModeStatus, requireIntegrationMode, isIntegrationOptIn, INTEGRATION_PLUGIN_NAME, getIntegrationPluginDir, } = await import("#kernel/integrationMode.js");
|
|
9
|
+
const { _resetTestConfigForTests, TEST_CHAT_ENV, RUN_WHATSAPP_TESTS_ENV, } = await import("#kernel/testConfig.js");
|
|
10
|
+
const tomlPath = path.join(configDir, "manybot.toml");
|
|
11
|
+
function clearEnv() {
|
|
12
|
+
delete process.env[TEST_CHAT_ENV];
|
|
13
|
+
delete process.env[RUN_WHATSAPP_TESTS_ENV];
|
|
14
|
+
delete process.env.MANYBOT_RUN_WHATSAPP_TESTS;
|
|
15
|
+
}
|
|
16
|
+
before(async () => {
|
|
17
|
+
await fs.writeFile(tomlPath, "", "utf8");
|
|
18
|
+
});
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
clearEnv();
|
|
21
|
+
await fs.writeFile(tomlPath, "", "utf8");
|
|
22
|
+
_resetTestConfigForTests();
|
|
23
|
+
});
|
|
24
|
+
after(async () => {
|
|
25
|
+
clearEnv();
|
|
26
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
describe("kernel/integrationMode — isIntegrationOptIn", () => {
|
|
29
|
+
test("false when env is unset", () => {
|
|
30
|
+
assert.equal(isIntegrationOptIn(), false);
|
|
31
|
+
});
|
|
32
|
+
test("false when env is anything other than '1'", () => {
|
|
33
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "true";
|
|
34
|
+
assert.equal(isIntegrationOptIn(), false);
|
|
35
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "0";
|
|
36
|
+
assert.equal(isIntegrationOptIn(), false);
|
|
37
|
+
});
|
|
38
|
+
test("true only when env equals '1'", () => {
|
|
39
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
|
|
40
|
+
assert.equal(isIntegrationOptIn(), true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("kernel/integrationMode — getIntegrationModeStatus", () => {
|
|
44
|
+
test("not ready when opt-in is missing (even with TEST_CHAT set)", async () => {
|
|
45
|
+
process.env[TEST_CHAT_ENV] = "5516000000001";
|
|
46
|
+
const status = await getIntegrationModeStatus();
|
|
47
|
+
assert.equal(status.ready, false);
|
|
48
|
+
assert.match(status.reason, /MANYBOT_RUN_WHATSAPP_TESTS=1/);
|
|
49
|
+
assert.equal(status.chat, "5516000000001@s.whatsapp.net");
|
|
50
|
+
});
|
|
51
|
+
test("not ready when TEST_CHAT is missing (even with opt-in)", async () => {
|
|
52
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
|
|
53
|
+
const status = await getIntegrationModeStatus();
|
|
54
|
+
assert.equal(status.ready, false);
|
|
55
|
+
assert.match(status.reason, /TEST_CHAT is not set/);
|
|
56
|
+
assert.equal(status.chat, null);
|
|
57
|
+
});
|
|
58
|
+
test("ready only when both opt-in and TEST_CHAT are configured", async () => {
|
|
59
|
+
process.env[TEST_CHAT_ENV] = "5516000000002";
|
|
60
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
|
|
61
|
+
const status = await getIntegrationModeStatus();
|
|
62
|
+
assert.equal(status.ready, true);
|
|
63
|
+
assert.equal(status.reason, null);
|
|
64
|
+
assert.equal(status.chat, "5516000000002@s.whatsapp.net");
|
|
65
|
+
assert.equal(status.source, "env");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("kernel/integrationMode — requireIntegrationMode", () => {
|
|
69
|
+
test("returns chat and source when ready", async () => {
|
|
70
|
+
process.env[TEST_CHAT_ENV] = "5516000000003";
|
|
71
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
|
|
72
|
+
const res = await requireIntegrationMode();
|
|
73
|
+
assert.equal(res.chat, "5516000000003@s.whatsapp.net");
|
|
74
|
+
assert.equal(res.source, "env");
|
|
75
|
+
});
|
|
76
|
+
test("throws when opt-in is missing", async () => {
|
|
77
|
+
process.env[TEST_CHAT_ENV] = "5516000000004";
|
|
78
|
+
await assert.rejects(requireIntegrationMode(), /MANYBOT_RUN_WHATSAPP_TESTS=1/);
|
|
79
|
+
});
|
|
80
|
+
test("throws when TEST_CHAT is missing", async () => {
|
|
81
|
+
process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
|
|
82
|
+
await assert.rejects(requireIntegrationMode(), /TEST_CHAT is not set/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("kernel/integrationMode — constants", () => {
|
|
86
|
+
test("integration plugin name is reserved (double-underscore)", () => {
|
|
87
|
+
assert.equal(INTEGRATION_PLUGIN_NAME, "__manybot_integration__");
|
|
88
|
+
assert.ok(INTEGRATION_PLUGIN_NAME.startsWith("__"));
|
|
89
|
+
assert.ok(INTEGRATION_PLUGIN_NAME.endsWith("__"));
|
|
90
|
+
});
|
|
91
|
+
test("integration plugin dir resolves under src/plugins/", () => {
|
|
92
|
+
const dir = getIntegrationPluginDir();
|
|
93
|
+
assert.ok(dir.endsWith(path.join("src", "plugins", "__manybot_integration__")));
|
|
94
|
+
});
|
|
95
|
+
});
|