@manybot/manybot 5.7.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/drivers/baileys/adapter.js +58 -7
  9. package/dist/drivers/baileys/api/index.js +172 -24
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +74 -17
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,281 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import test from "node:test";
4
+ import { createStore } from "#client/store.js";
5
+ import { createBaileysAdapter } from "#drivers/baileys/adapter.js";
6
+ test("Baileys sends messages with the chat's disappearing-message timer", async () => {
7
+ const store = createStore();
8
+ const jid = "chat@s.whatsapp.net";
9
+ store.setChatEphemeralExpiration(jid, 86400);
10
+ store.messages.set(jid, new Map([["quoted", {
11
+ key: { id: "quoted", remoteJid: jid, fromMe: false },
12
+ message: { conversation: "original" },
13
+ }]]));
14
+ const calls = [];
15
+ const sock = {
16
+ ev: new EventEmitter(),
17
+ user: { id: "bot@s.whatsapp.net" },
18
+ sendMessage: async (...args) => {
19
+ calls.push(args);
20
+ return { key: { id: `sent-${calls.length}`, remoteJid: args[0] } };
21
+ },
22
+ groupMetadata: async () => ({ subject: "Group", participants: [], ephemeralDuration: 604800 }),
23
+ };
24
+ const { contract } = createBaileysAdapter({ sock, store });
25
+ const quoted = { id: "quoted", remoteJid: jid, fromMe: false };
26
+ await contract.sendText(jid, "text", { quoted });
27
+ await contract.sendImage(jid, Buffer.from("image"));
28
+ await contract.sendVideo(jid, Buffer.from("video"));
29
+ await contract.sendAudio(jid, Buffer.from("audio"));
30
+ await contract.sendSticker(jid, Buffer.from("sticker"));
31
+ await contract.sendDocument(jid, Buffer.from("document"), "file.txt", "text/plain");
32
+ await contract.sendPoll(jid, { name: "Poll", values: ["one"] });
33
+ for (const call of calls) {
34
+ assert.equal(call[2]?.ephemeralExpiration, 86400);
35
+ }
36
+ assert.ok((calls[0]?.[2]).quoted, "quoted and ephemeral options are merged");
37
+ await contract.react(jid, quoted, "👍");
38
+ assert.equal(calls.at(-1)?.length, 2, "reactions do not receive ephemeral send options");
39
+ await contract.groupMetadata("group@g.us");
40
+ await contract.sendText("group@g.us", "group text");
41
+ assert.equal((calls.at(-1)?.[2]).ephemeralExpiration, 604800);
42
+ });
43
+ test("Baileys adapter getBusinessProfile handles success and failure", async () => {
44
+ const store = createStore();
45
+ const jid = "12345678@s.whatsapp.net";
46
+ const sock = {
47
+ ev: new EventEmitter(),
48
+ user: { id: "bot@s.whatsapp.net" },
49
+ getBusinessProfile: async () => ({ name: "Test Corp", id: "12345678" }),
50
+ };
51
+ const { contract } = createBaileysAdapter({ sock, store });
52
+ const result = await contract.getBusinessProfile(jid);
53
+ assert.deepStrictEqual(result, { name: "Test Corp", id: "12345678" });
54
+ const sockFail = {
55
+ ev: new EventEmitter(),
56
+ user: { id: "bot@s.whatsapp.net" },
57
+ getBusinessProfile: async () => { throw new Error("API error"); },
58
+ };
59
+ const { contract: contractFail } = createBaileysAdapter({ sock: sockFail, store });
60
+ const resultFail = await contractFail.getBusinessProfile(jid);
61
+ assert.strictEqual(resultFail, null);
62
+ });
63
+ test("Baileys adapter profilePictureUrl retrieves URL and handles error", async () => {
64
+ const store = createStore();
65
+ const jid = "12345678@s.whatsapp.net";
66
+ const sock = {
67
+ ev: new EventEmitter(),
68
+ user: { id: "bot@s.whatsapp.net" },
69
+ profilePictureUrl: async () => "https://example.com/profile.jpg",
70
+ };
71
+ const { contract } = createBaileysAdapter({ sock, store });
72
+ const result = await contract.profilePictureUrl(jid);
73
+ assert.strictEqual(result, "https://example.com/profile.jpg");
74
+ const sockFail = {
75
+ ev: new EventEmitter(),
76
+ user: { id: "bot@s.whatsapp.net" },
77
+ profilePictureUrl: async () => { throw new Error("API error"); },
78
+ };
79
+ const { contract: contractFail } = createBaileysAdapter({ sock: sockFail, store });
80
+ const resultFail = await contractFail.profilePictureUrl(jid);
81
+ assert.strictEqual(resultFail, null);
82
+ });
83
+ test("Baileys adapter fetchStatus retrieves status and handles error", async () => {
84
+ const store = createStore();
85
+ const jid = "12345678@s.whatsapp.net";
86
+ const sock = {
87
+ ev: new EventEmitter(),
88
+ user: { id: "bot@s.whatsapp.net" },
89
+ fetchStatus: async () => [{ id: jid, status: { status: "available" } }],
90
+ };
91
+ const { contract } = createBaileysAdapter({ sock, store });
92
+ const result = await contract.fetchStatus(jid);
93
+ assert.strictEqual(result, "available");
94
+ const sockNoArray = {
95
+ ev: new EventEmitter(),
96
+ user: { id: "bot@s.whatsapp.net" },
97
+ fetchStatus: async () => ({ status: "away" }),
98
+ };
99
+ const { contract: contractNoArray } = createBaileysAdapter({ sock: sockNoArray, store });
100
+ const resultNoArray = await contractNoArray.fetchStatus(jid);
101
+ assert.strictEqual(resultNoArray, "away");
102
+ const sockFail = {
103
+ ev: new EventEmitter(),
104
+ user: { id: "bot@s.whatsapp.net" },
105
+ fetchStatus: async () => { throw new Error("API error"); },
106
+ };
107
+ const { contract: contractFail } = createBaileysAdapter({ sock: sockFail, store });
108
+ const resultFail = await contractFail.fetchStatus(jid);
109
+ assert.strictEqual(resultFail, null);
110
+ });
111
+ test("Baileys adapter updateBlockStatus calls the underlying method", async () => {
112
+ const store = createStore();
113
+ const jid = "12345678@s.whatsapp.net";
114
+ const sock = {
115
+ ev: new EventEmitter(),
116
+ user: { id: "bot@s.whatsapp.net" },
117
+ updateBlockStatus: async () => { },
118
+ };
119
+ const { contract } = createBaileysAdapter({ sock, store });
120
+ await contract.updateBlockStatus(jid, "block");
121
+ assert.ok(true, "updateBlockStatus did not throw");
122
+ await contract.updateBlockStatus(jid, "unblock");
123
+ assert.ok(true, "updateBlockStatus remove did not throw");
124
+ });
125
+ test("Baileys adapter addOrEditContact calls the underlying method", async () => {
126
+ const store = createStore();
127
+ const jid = "12345678@s.whatsapp.net";
128
+ const info = { fullName: "Test User", firstName: "Test", saveOnPrimaryAddressbook: true };
129
+ const sock = {
130
+ ev: new EventEmitter(),
131
+ user: { id: "bot@s.whatsapp.net" },
132
+ addOrEditContact: async () => { },
133
+ };
134
+ const { contract } = createBaileysAdapter({ sock, store });
135
+ await contract.addOrEditContact(jid, info);
136
+ assert.ok(true, "addOrEditContact did not throw");
137
+ await contract.addOrEditContact(jid, { fullName: "Another User" });
138
+ assert.ok(true, "addOrEditContact second call did not throw");
139
+ });
140
+ test("Baileys adapter removeContact calls the underlying method", async () => {
141
+ const store = createStore();
142
+ const jid = "12345678@s.whatsapp.net";
143
+ const sock = {
144
+ ev: new EventEmitter(),
145
+ user: { id: "bot@s.whatsapp.net" },
146
+ removeContact: async () => { },
147
+ };
148
+ const { contract } = createBaileysAdapter({ sock, store });
149
+ await contract.removeContact(jid);
150
+ assert.ok(true, "removeContact did not throw");
151
+ });
152
+ test("Baileys adapter groupParticipantsUpdate calls the underlying method", async () => {
153
+ const store = createStore();
154
+ const jid = "group@g.us";
155
+ const users = ["user1@s.whatsapp.net", "user2@s.whatsapp.net"];
156
+ const action = "add";
157
+ const sock = {
158
+ ev: new EventEmitter(),
159
+ user: { id: "bot@s.whatsapp.net" },
160
+ groupParticipantsUpdate: async () => [{ status: "success" }],
161
+ };
162
+ const { contract } = createBaileysAdapter({ sock, store });
163
+ const result = await contract.groupParticipantsUpdate(jid, users, action);
164
+ assert.deepStrictEqual(result, [{ status: "success" }]);
165
+ await contract.groupParticipantsUpdate(jid, ["user3@s.whatsapp.net"], "remove");
166
+ assert.ok(true, "groupParticipantsUpdate remove did not throw");
167
+ });
168
+ test("Baileys adapter groupUpdateSubject and groupUpdateDescription call underlying methods", async () => {
169
+ const store = createStore();
170
+ const jid = "group@g.us";
171
+ const sock = {
172
+ ev: new EventEmitter(),
173
+ user: { id: "bot@s.whatsapp.net" },
174
+ groupUpdateSubject: async () => { },
175
+ groupUpdateDescription: async () => { },
176
+ };
177
+ const { contract } = createBaileysAdapter({ sock, store });
178
+ await contract.groupUpdateSubject(jid, "New Subject");
179
+ assert.ok(true, "groupUpdateSubject did not throw");
180
+ await contract.groupUpdateDescription(jid, "New Description");
181
+ assert.ok(true, "groupUpdateDescription did not throw");
182
+ });
183
+ test("Baileys adapter groupInviteCode and groupRevokeInvite call underlying methods", async () => {
184
+ const store = createStore();
185
+ const jid = "group@g.us";
186
+ const sock = {
187
+ ev: new EventEmitter(),
188
+ user: { id: "bot@s.whatsapp.net" },
189
+ groupInviteCode: async () => "https://chat.whatsapp.com/AAAA",
190
+ groupRevokeInvite: async () => { },
191
+ };
192
+ const { contract } = createBaileysAdapter({ sock, store });
193
+ const code = await contract.groupInviteCode(jid);
194
+ assert.strictEqual(code, "https://chat.whatsapp.com/AAAA");
195
+ await contract.groupRevokeInvite(jid);
196
+ assert.ok(true, "groupRevokeInvite did not throw");
197
+ });
198
+ test("Baileys adapter updateProfilePicture, updateProfileName, updateProfileStatus call underlying methods", async () => {
199
+ const store = createStore();
200
+ const buffer = Buffer.from("fake-image-data");
201
+ const sock = {
202
+ ev: new EventEmitter(),
203
+ user: { id: "bot@s.whatsapp.net" },
204
+ updateProfilePicture: async () => { },
205
+ updateProfileName: async () => { },
206
+ updateProfileStatus: async () => { },
207
+ };
208
+ const { contract } = createBaileysAdapter({ sock, store });
209
+ await contract.updateProfilePicture("12345678@s.whatsapp.net", buffer);
210
+ assert.ok(true, "updateProfilePicture did not throw");
211
+ await contract.updateProfileName("New Name");
212
+ assert.ok(true, "updateProfileName did not throw");
213
+ await contract.updateProfileStatus("Available");
214
+ assert.ok(true, "updateProfileStatus did not throw");
215
+ });
216
+ test("Baileys adapter me returns user info", async () => {
217
+ const store = createStore();
218
+ const sock = {
219
+ ev: new EventEmitter(),
220
+ user: { id: "bot@s.whatsapp.net", lid: "12345678@lid" },
221
+ };
222
+ const { contract } = createBaileysAdapter({ sock, store });
223
+ const me = await contract.me();
224
+ assert.strictEqual(me.id, "bot@s.whatsapp.net");
225
+ assert.strictEqual(me.lid, "12345678@lid");
226
+ });
227
+ test("Baileys adapter getHistory returns messages from store", async () => {
228
+ const store = createStore();
229
+ const jid = "12345678@s.whatsapp.net";
230
+ store.messages.set(jid, new Map([
231
+ ["msg1", { key: { id: "msg1", remoteJid: jid, fromMe: false }, message: { conversation: "hello" } }],
232
+ ["msg2", { key: { id: "msg2", remoteJid: jid, fromMe: true }, message: { conversation: "world" } }],
233
+ ]));
234
+ const sock = {
235
+ ev: new EventEmitter(),
236
+ user: { id: "bot@s.whatsapp.net" },
237
+ };
238
+ const { contract } = createBaileysAdapter({ sock, store });
239
+ const history = await contract.getHistory?.(jid, { limit: 2 });
240
+ assert.strictEqual(history?.length, 2);
241
+ assert.ok(history?.some((m) => m.body === "hello"));
242
+ assert.ok(history?.some((m) => m.body === "world"));
243
+ });
244
+ test("Baileys adapter downloadMedia returns null when no raw message", async () => {
245
+ const store = createStore();
246
+ const sock = {
247
+ ev: new EventEmitter(),
248
+ user: { id: "bot@s.whatsapp.net" },
249
+ };
250
+ const { contract } = createBaileysAdapter({ sock, store });
251
+ const result = await contract.downloadMedia({ chatId: "123", id: "msg1" }, {});
252
+ assert.strictEqual(result, null);
253
+ });
254
+ test("Baileys adapter decryptPollVote returns null when no vote data", async () => {
255
+ const store = createStore();
256
+ const sock = {
257
+ ev: new EventEmitter(),
258
+ user: { id: "bot@s.whatsapp.net" },
259
+ };
260
+ const { contract } = createBaileysAdapter({ sock, store });
261
+ const result = await contract.decryptPollVote?.({
262
+ voteKey: { remoteJid: "123", id: "msg1" },
263
+ pollKey: { remoteJid: "123", id: "msg1" },
264
+ pollEncKey: Buffer.alloc(0),
265
+ });
266
+ assert.strictEqual(result, null);
267
+ });
268
+ test("Baileys adapter aggregatePollVotes returns empty array when no poll data", async () => {
269
+ const store = createStore();
270
+ const sock = {
271
+ ev: new EventEmitter(),
272
+ user: { id: "bot@s.whatsapp.net" },
273
+ };
274
+ const { contract } = createBaileysAdapter({ sock, store });
275
+ const result = contract.aggregatePollVotes?.({
276
+ pollKey: { remoteJid: "123", id: "msg1" },
277
+ selfJid: undefined,
278
+ votes: [],
279
+ });
280
+ assert.deepStrictEqual(result, []);
281
+ });
@@ -0,0 +1,40 @@
1
+ import test, { describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { normalizeJid, denormalizeJid, toWireJid } from "#drivers/jid.js";
4
+ describe("drivers/jid", () => {
5
+ describe("normalizeJid", () => {
6
+ test("handles empty string or nullish input", () => {
7
+ assert.equal(normalizeJid(""), "");
8
+ });
9
+ test("converts @s.whatsapp.net to @c.us", () => {
10
+ assert.equal(normalizeJid("5511999999999@s.whatsapp.net"), "5511999999999@c.us");
11
+ });
12
+ test("removes device suffix e.g. :12@", () => {
13
+ assert.equal(normalizeJid("5511999999999:12@s.whatsapp.net"), "5511999999999@c.us");
14
+ });
15
+ test("leaves group @g.us untouched", () => {
16
+ assert.equal(normalizeJid("12036301234567890@g.us"), "12036301234567890@g.us");
17
+ });
18
+ });
19
+ describe("denormalizeJid", () => {
20
+ test("converts @c.us to @s.whatsapp.net", () => {
21
+ assert.equal(denormalizeJid("5511999999999@c.us"), "5511999999999@s.whatsapp.net");
22
+ });
23
+ test("leaves already wire or non-c.us JIDs untouched", () => {
24
+ assert.equal(denormalizeJid("12036301234567890@g.us"), "12036301234567890@g.us");
25
+ });
26
+ });
27
+ describe("toWireJid", () => {
28
+ test("returns wire format for @s.whatsapp.net, @lid, @g.us untouched", () => {
29
+ assert.equal(toWireJid("5511999999999@s.whatsapp.net"), "5511999999999@s.whatsapp.net");
30
+ assert.equal(toWireJid("12345@lid"), "12345@lid");
31
+ assert.equal(toWireJid("12036301234567890@g.us"), "12036301234567890@g.us");
32
+ });
33
+ test("converts @c.us JID to @s.whatsapp.net", () => {
34
+ assert.equal(toWireJid("5511999999999@c.us"), "5511999999999@s.whatsapp.net");
35
+ });
36
+ test("converts plain phone number / digits to @s.whatsapp.net", () => {
37
+ assert.equal(toWireJid("+55 (11) 99999-9999"), "5511999999999@s.whatsapp.net");
38
+ });
39
+ });
40
+ });
@@ -2,11 +2,11 @@
2
2
  * src/drivers/types.ts
3
3
  *
4
4
  * Driver-neutral envelope types shared by every WhatsApp driver
5
- * implementation (Baileys today, whatsmeow in a later phase). The
6
- * full driver surface (send, react, presence, contacts, groups,
7
- * profile, media) is declared as `WaContract` in `#kernel/waContract.js`
8
- * every driver implements it. This module holds only the message
9
- * shapes that flow across the driver boundary.
5
+ * implementation (Baileys today). The full driver surface (send, react,
6
+ * presence, contacts, groups, profile, media) is declared as
7
+ * `WaContract` in `#kernel/waContract.js` — every driver implements it.
8
+ * This module holds only the message shapes that flow across the driver
9
+ * boundary.
10
10
  *
11
11
  * Plugins depend on these types through the `WaContract` re-exports,
12
12
  * never on a specific driver's Baileys/grpc types.
@@ -125,11 +125,24 @@ function interpolate(str, context = {}) {
125
125
  }
126
126
  export function t(key, context = {}) {
127
127
  ensureLoaded();
128
+ return translate(currentTranslations, fallbackTranslations, key, context);
129
+ }
130
+ /**
131
+ * Translates a key for an explicit language. This is useful for rendered
132
+ * content that accepts a language override, such as the command menu.
133
+ */
134
+ export function tFor(lang, key, context = {}) {
135
+ ensureLoaded();
136
+ const targetLang = lang?.trim().toLowerCase() || currentLang || DEFAULT_LANG;
137
+ const targetTranslations = loadLocale(targetLang) || fallbackTranslations;
138
+ return translate(targetTranslations, fallbackTranslations, key, context);
139
+ }
140
+ function translate(targetTranslations, englishTranslations, key, context) {
128
141
  // Try current language first
129
- let value = getNestedValue(currentTranslations, key);
142
+ let value = getNestedValue(targetTranslations, key);
130
143
  // Fallback to English if not found
131
144
  if (value === undefined) {
132
- value = getNestedValue(fallbackTranslations, key);
145
+ value = getNestedValue(englishTranslations, key);
133
146
  }
134
147
  // If still not found, return the key
135
148
  if (value === undefined) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * activeDriverSend.ts
3
+ *
4
+ * Driver-neutral send helper. Ensures rate-limiting and throttling
5
+ * are applied to all outbound text sends from plugins, then sends
6
+ * through whichever single driver is currently active in the
7
+ * DriverManager. There is no fallback between drivers — driver
8
+ * selection is mutually exclusive and decided once at boot (see
9
+ * main.ts / driverManager.ts).
10
+ */
11
+ import { waitForSendSlot } from "./sendGuard.js";
12
+ import { getDriverManager } from "./driverManager.js";
13
+ /**
14
+ * Send text using the active driver, respecting the rate-limiting send guard.
15
+ */
16
+ export async function sendActiveDriverText(jid, text, opts = {}) {
17
+ const dm = getDriverManager();
18
+ const driver = dm.active();
19
+ await waitForSendSlot(jid, { cooldown: true, jitter: true });
20
+ return await driver.sendText(jid, text, opts);
21
+ }
@@ -0,0 +1,89 @@
1
+ import test, { describe, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { sendActiveDriverText } from "#kernel/activeDriverSend.js";
4
+ import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
5
+ function createMockDriver(name, ready = true, failsSend = false) {
6
+ const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
7
+ return {
8
+ name,
9
+ isReady: () => ready,
10
+ sendText: async (jid, text) => {
11
+ if (failsSend)
12
+ throw new Error(`${name} sendText failed`);
13
+ return mockRef(`msg_${name}`);
14
+ },
15
+ connect: async () => { },
16
+ disconnect: async () => { },
17
+ me: () => ({ id: "123@c.us" }),
18
+ sendImage: async () => mockRef("image"),
19
+ sendVideo: async () => mockRef("image"),
20
+ sendAudio: async () => mockRef("image"),
21
+ sendDocument: async () => mockRef("image"),
22
+ sendSticker: async () => mockRef("image"),
23
+ sendLocation: async () => mockRef("loc"),
24
+ sendContact: async () => mockRef("contact"),
25
+ sendReaction: async () => { },
26
+ sendPoll: async () => mockRef("poll"),
27
+ react: async () => { },
28
+ deleteMessage: async () => { },
29
+ editMessage: async () => { },
30
+ sendPresenceUpdate: async () => { },
31
+ readMessages: async () => { },
32
+ onWhatsApp: async () => null,
33
+ getBusinessProfile: async () => null,
34
+ profilePictureUrl: async () => null,
35
+ fetchStatus: async () => null,
36
+ updateBlockStatus: async () => { },
37
+ addOrEditContact: async () => { },
38
+ removeContact: async () => { },
39
+ groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
40
+ groupParticipantsUpdate: async () => [],
41
+ groupUpdateSubject: async () => { },
42
+ groupUpdateDescription: async () => { },
43
+ groupInviteCode: async () => "",
44
+ groupRevokeInvite: async () => "",
45
+ updateProfilePicture: async () => { },
46
+ updateProfileName: async () => { },
47
+ updateProfileStatus: async () => { },
48
+ downloadMedia: async () => null,
49
+ on: () => () => { },
50
+ };
51
+ }
52
+ describe("kernel/activeDriverSend", () => {
53
+ beforeEach(() => {
54
+ _resetDriverManagerForTests();
55
+ });
56
+ test("delivers text via the active driver when healthy", async () => {
57
+ const dm = getDriverManager();
58
+ const driver = createMockDriver("baileys");
59
+ dm.register(driver, { isPrimary: true });
60
+ const ref = await sendActiveDriverText("5511999999999@c.us", "hello");
61
+ assert.equal(ref.id, "msg_baileys");
62
+ });
63
+ test("propagates error when the active driver fails to send", async () => {
64
+ const dm = getDriverManager();
65
+ const failingDriver = createMockDriver("baileys", true, true);
66
+ dm.register(failingDriver, { isPrimary: true });
67
+ await assert.rejects(async () => sendActiveDriverText("5511999999999@c.us", "failing send"), /baileys sendText failed/);
68
+ });
69
+ test("passes quoted and mentions options through to the driver", async () => {
70
+ const dm = getDriverManager();
71
+ let receivedOpts = null;
72
+ const driver = {
73
+ ...createMockDriver("baileys"),
74
+ sendText: async (_jid, _text, opts) => {
75
+ receivedOpts = opts;
76
+ return { id: "msg1", chatId: _jid, timestamp: Date.now() };
77
+ },
78
+ };
79
+ dm.register(driver, { isPrimary: true });
80
+ const quotedRef = { id: "orig-msg", remoteJid: "123@s.whatsapp.net", fromMe: false };
81
+ const ref = await sendActiveDriverText("5511999999999@c.us", "reply", { quoted: quotedRef, mentions: ["@user1"] });
82
+ assert.equal(ref.id, "msg1");
83
+ assert.deepStrictEqual(receivedOpts, { quoted: quotedRef, mentions: ["@user1"] });
84
+ });
85
+ test("throws when no driver is registered", async () => {
86
+ const dm = getDriverManager();
87
+ await assert.rejects(async () => sendActiveDriverText("5511999999999@c.us", "no driver"), /no active driver/i);
88
+ });
89
+ });
@@ -29,6 +29,7 @@ import { spawn } from "child_process";
29
29
  import nodemailer from "nodemailer";
30
30
  import { CONFIG_DIR, ADMIN_JID, SMTP_HOST, SMTP_PORT, SMTP_SEC, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TO, SMTP_INSECURE, } from "#config";
31
31
  import { logger } from "#logger";
32
+ import { t } from "#i18n";
32
33
  const ALERTS_LOG_FILE = path.join(CONFIG_DIR, "alerts.log");
33
34
  let sockProvider = null;
34
35
  /**
@@ -160,25 +161,18 @@ export function fireAlert(kind, details = {}) {
160
161
  if (kind === "send_failed_no_fallback") {
161
162
  event = {
162
163
  level: "critical",
163
- title: "manybot: sem driver de fallback",
164
+ title: t("alerts.noFallbackTitle"),
164
165
  message: `jid=${details.jid} primary=${details.primary}`,
165
166
  };
166
167
  }
167
168
  else if (kind === "send_failed_both_drivers") {
168
169
  event = {
169
170
  level: "critical",
170
- title: "manybot: envio falhou nos dois drivers",
171
+ title: t("alerts.bothDriversFailedTitle"),
171
172
  message: `jid=${details.jid} ${details.primary}->${details.secondary}` +
172
173
  (details.error ? ` error=${String(details.error)}` : ""),
173
174
  };
174
175
  }
175
- else if (kind === "whatsmeow_subprocess_halted") {
176
- event = {
177
- level: "critical",
178
- title: "manybot: subprocesso whatsmeow halted",
179
- message: `fallback indisponível: ${details.reason ?? "unknown"} — bot segue só com Baileys`,
180
- };
181
- }
182
176
  else {
183
177
  event = {
184
178
  level: "warning",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * chatSession.ts
3
+ *
4
+ * Phase 7 of MANYBOT-6.md — exclusive chat session, kernel primitive.
5
+ *
6
+ * Prevents two plugins from running an interactive flow (a game, the
7
+ * figurinha timeout session, a music-download prompt, etc.) in the same
8
+ * chat at the same time. The kernel only owns the lock itself — WHO holds
9
+ * it and for how long. Everything about the session's own state (timeout,
10
+ * collected media, turn tracking, ...) stays entirely inside the owning
11
+ * plugin; `commands.yaml` only ever registers commands, never internal
12
+ * flow state.
13
+ *
14
+ * Deliberately NOT persisted (no settingsDb / SQLite): a session lock only
15
+ * makes sense for the lifetime of the running process — restarting the
16
+ * bot should never leave a chat stuck "locked" by a plugin that no longer
17
+ * remembers it opened one.
18
+ *
19
+ * many-ai's passive continuation window (its own multi-turn follow-up
20
+ * mechanism) is a separate category by design and never touches this
21
+ * lock — see the Phase 7 note in MANYBOT-6.md. This module does not
22
+ * special-case many-ai; it simply never gets called by it.
23
+ */
24
+ const sessions = new Map();
25
+ /**
26
+ * Attempts to open an exclusive session for `pluginName` in `chatId`.
27
+ * Returns `true` if the session is now held by `pluginName` — either it
28
+ * was free, or `pluginName` already held it (idempotent re-acquire, e.g.
29
+ * a plugin calling acquire() again on a later message of its own flow).
30
+ * Returns `false` if another plugin already holds the session.
31
+ */
32
+ export function acquireSession(chatId, pluginName) {
33
+ const current = sessions.get(chatId);
34
+ if (current && current.pluginName !== pluginName) {
35
+ return false;
36
+ }
37
+ sessions.set(chatId, { pluginName, acquiredAt: current?.acquiredAt ?? Date.now() });
38
+ return true;
39
+ }
40
+ /**
41
+ * Releases the session in `chatId`, but only if it is currently held by
42
+ * `pluginName` — a plugin can never release a lock it doesn't own. No-op
43
+ * (returns `false`) if the chat has no session, or it's held by someone
44
+ * else.
45
+ */
46
+ export function releaseSession(chatId, pluginName) {
47
+ const current = sessions.get(chatId);
48
+ if (!current || current.pluginName !== pluginName) {
49
+ return false;
50
+ }
51
+ sessions.delete(chatId);
52
+ return true;
53
+ }
54
+ /** Whether `chatId` currently has an open exclusive session (by anyone). */
55
+ export function isSessionLocked(chatId) {
56
+ return sessions.has(chatId);
57
+ }
58
+ /** Which plugin currently holds the session in `chatId`, if any. */
59
+ export function getSessionHolder(chatId) {
60
+ return sessions.get(chatId)?.pluginName ?? null;
61
+ }
62
+ /** Test-only: wipe all session state between tests. */
63
+ export function __resetSessionsForTests() {
64
+ sessions.clear();
65
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { describe, beforeEach } from "node:test";
3
+ import { acquireSession, releaseSession, isSessionLocked, getSessionHolder, __resetSessionsForTests, } from "#kernel/chatSession.js";
4
+ describe("kernel/chatSession — Phase 7 exclusive chat session", () => {
5
+ beforeEach(() => {
6
+ __resetSessionsForTests();
7
+ });
8
+ test("acquire on a free chat succeeds and locks it", () => {
9
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
10
+ assert.equal(isSessionLocked("chat1"), true);
11
+ assert.equal(getSessionHolder("chat1"), "gamePlugin");
12
+ });
13
+ test("a different plugin cannot acquire an already-held session", () => {
14
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
15
+ assert.equal(acquireSession("chat1", "figurinhaPlugin"), false);
16
+ assert.equal(getSessionHolder("chat1"), "gamePlugin", "holder unchanged");
17
+ });
18
+ test("the same plugin re-acquiring its own session is idempotent", () => {
19
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
20
+ assert.equal(acquireSession("chat1", "gamePlugin"), true);
21
+ assert.equal(getSessionHolder("chat1"), "gamePlugin");
22
+ });
23
+ test("release only works for the plugin that holds the session", () => {
24
+ acquireSession("chat1", "gamePlugin");
25
+ assert.equal(releaseSession("chat1", "figurinhaPlugin"), false, "wrong plugin cannot release");
26
+ assert.equal(isSessionLocked("chat1"), true, "still locked");
27
+ assert.equal(releaseSession("chat1", "gamePlugin"), true);
28
+ assert.equal(isSessionLocked("chat1"), false);
29
+ assert.equal(getSessionHolder("chat1"), null);
30
+ });
31
+ test("releasing a chat with no session is a no-op", () => {
32
+ assert.equal(releaseSession("neverLocked", "anyPlugin"), false);
33
+ });
34
+ test("sessions are independent per chat", () => {
35
+ assert.equal(acquireSession("chatA", "gamePlugin"), true);
36
+ assert.equal(acquireSession("chatB", "figurinhaPlugin"), true);
37
+ assert.equal(getSessionHolder("chatA"), "gamePlugin");
38
+ assert.equal(getSessionHolder("chatB"), "figurinhaPlugin");
39
+ });
40
+ test("a freed session can be acquired by a different plugin afterward", () => {
41
+ acquireSession("chat1", "gamePlugin");
42
+ releaseSession("chat1", "gamePlugin");
43
+ assert.equal(acquireSession("chat1", "figurinhaPlugin"), true);
44
+ assert.equal(getSessionHolder("chat1"), "figurinhaPlugin");
45
+ });
46
+ });