@manybot/manybot 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +28 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +91 -6
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/download/queue.js +13 -4
  9. package/dist/drivers/baileys/adapter.js +133 -15
  10. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  11. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  12. package/dist/drivers/baileys/api/index.js +384 -62
  13. package/dist/drivers/baileys/index.js +92 -36
  14. package/dist/drivers/baileys/loginPrompt.js +0 -2
  15. package/dist/drivers/baileys/messageHandler.js +344 -4
  16. package/dist/drivers/baileys/messageHandler.test.js +445 -0
  17. package/dist/drivers/baileysAdapter.test.js +378 -0
  18. package/dist/drivers/jid.js +26 -0
  19. package/dist/drivers/jid.test.js +74 -0
  20. package/dist/drivers/types.js +5 -5
  21. package/dist/i18n/index.js +20 -24
  22. package/dist/kernel/activeDriverSend.js +21 -0
  23. package/dist/kernel/activeDriverSend.test.js +89 -0
  24. package/dist/kernel/alerts.js +3 -9
  25. package/dist/kernel/chatOverrides.js +46 -0
  26. package/dist/kernel/chatOverrides.test.js +59 -0
  27. package/dist/kernel/chatSession.js +65 -0
  28. package/dist/kernel/chatSession.test.js +46 -0
  29. package/dist/kernel/commandAccess.js +66 -0
  30. package/dist/kernel/commandAccess.test.js +74 -0
  31. package/dist/kernel/commandDeprecation.js +170 -0
  32. package/dist/kernel/commandDeprecation.test.js +114 -0
  33. package/dist/kernel/commandMenu.js +357 -0
  34. package/dist/kernel/commandMenu.test.js +363 -0
  35. package/dist/kernel/commandPermissions.js +171 -0
  36. package/dist/kernel/commandPermissions.test.js +227 -0
  37. package/dist/kernel/commandRegistry.js +583 -0
  38. package/dist/kernel/commandRegistry.test.js +158 -0
  39. package/dist/kernel/commandsConfig.js +949 -0
  40. package/dist/kernel/commandsConfig.test.js +482 -0
  41. package/dist/kernel/contactAutoSave.js +6 -6
  42. package/dist/kernel/contactAutoSave.test.js +87 -0
  43. package/dist/kernel/coreCommands.js +62 -0
  44. package/dist/kernel/driverManager.js +10 -6
  45. package/dist/kernel/driverManager.test.js +90 -0
  46. package/dist/kernel/integrationMode.js +88 -0
  47. package/dist/kernel/integrationMode.test.js +95 -0
  48. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  49. package/dist/kernel/pluginApi.test.js +600 -0
  50. package/dist/kernel/pluginGuard.js +18 -13
  51. package/dist/kernel/pluginGuard.test.js +39 -0
  52. package/dist/kernel/pluginLoader.js +169 -11
  53. package/dist/kernel/pluginLoader.test.js +190 -0
  54. package/dist/kernel/runCommand.js +284 -0
  55. package/dist/kernel/runCommand.test.js +497 -0
  56. package/dist/kernel/sendFallbackGuard.js +19 -48
  57. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  58. package/dist/kernel/sendGuard.js +38 -42
  59. package/dist/kernel/sendGuard.test.js +102 -0
  60. package/dist/kernel/settingsDb.js +19 -5
  61. package/dist/kernel/statusServer.js +9 -2
  62. package/dist/kernel/statusServer.test.js +70 -0
  63. package/dist/kernel/testConfig.js +192 -0
  64. package/dist/kernel/testConfig.test.js +181 -0
  65. package/dist/kernel/updateCheck.js +33 -10
  66. package/dist/locales/en.json +77 -13
  67. package/dist/locales/es.json +77 -13
  68. package/dist/locales/pt.json +77 -13
  69. package/dist/logger/logger.js +23 -3
  70. package/dist/logger/logger.test.js +45 -0
  71. package/dist/main.js +5 -76
  72. package/dist/plugins/__manybot_integration__/index.js +184 -0
  73. package/dist/plugins/__manybot_integration__/index.test.js +218 -0
  74. package/dist/utils/phoneNumber.js +83 -0
  75. package/dist/utils/phoneNumber.test.js +53 -0
  76. package/package.json +76 -18
  77. package/dist/drivers/whatsmeow/client.js +0 -252
  78. package/dist/drivers/whatsmeow/index.js +0 -79
  79. package/dist/drivers/whatsmeow/installer.js +0 -86
  80. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  81. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -0,0 +1,600 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describe, test, beforeEach, afterEach } from "node:test";
6
+ import { createStore } from "#client/store.js";
7
+ import { buildApi, buildSetupApi, buildStorageApi, cleanupPluginEvents, } from "#kernel/pluginApi.js";
8
+ import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
9
+ import { __resetSessionsForTests } from "#kernel/chatSession.js";
10
+ // Setup temp config directory for tests
11
+ const testTmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-pluginapi-test-"));
12
+ process.env.MANYBOT_CONFIG_DIR = testTmpDir;
13
+ const RAW_SOCK_SYM = Symbol.for("manybot.baileys.rawSocket");
14
+ function createMockContract() {
15
+ const calls = {
16
+ sentTexts: [],
17
+ sentImages: [],
18
+ sentVideos: [],
19
+ sentAudios: [],
20
+ sentStickers: [],
21
+ sentDocuments: [],
22
+ sentPolls: [],
23
+ reactions: [],
24
+ edits: [],
25
+ deletes: [],
26
+ blockUpdates: [],
27
+ groupParticipantUpdates: [],
28
+ nameUpdates: [],
29
+ statusUpdates: [],
30
+ };
31
+ const listeners = new Map();
32
+ const rawMockSock = {
33
+ user: { id: "5516999999999:0@s.whatsapp.net", name: "ManyBot" },
34
+ groupMetadata: async (jid) => ({
35
+ id: jid,
36
+ subject: "Test Mock Group",
37
+ participants: [
38
+ { id: "5516999999999@s.whatsapp.net", admin: "admin", phoneNumber: "5516999999999@s.whatsapp.net" },
39
+ { id: "5516888888888@s.whatsapp.net", admin: "superadmin", phoneNumber: "5516888888888@s.whatsapp.net" },
40
+ { id: "5516777777777@s.whatsapp.net", admin: null, phoneNumber: "5516777777777@s.whatsapp.net" },
41
+ ],
42
+ }),
43
+ };
44
+ let msgSeq = 0;
45
+ const sentHistory = [];
46
+ const contract = {
47
+ name: "baileys",
48
+ connect: async () => { },
49
+ disconnect: async () => { },
50
+ isReady: () => true,
51
+ resolveLid: async (lid) => (lid === "12345@lid" ? "5516777777777@s.whatsapp.net" : null),
52
+ on: (event, handler) => {
53
+ if (!listeners.has(event))
54
+ listeners.set(event, new Set());
55
+ const set = listeners.get(event);
56
+ set.add(handler);
57
+ return () => {
58
+ set.delete(handler);
59
+ };
60
+ },
61
+ sendText: async (jid, text, opts) => {
62
+ const id = `msg-${++msgSeq}`;
63
+ calls.sentTexts.push({ jid, text, opts });
64
+ const ref = { id, chatId: jid, timestamp: Date.now() };
65
+ sentHistory.push({
66
+ id,
67
+ chatId: jid,
68
+ fromMe: true,
69
+ type: "text",
70
+ body: text,
71
+ contentHash: "hash-" + id,
72
+ timestamp: Date.now(),
73
+ });
74
+ return ref;
75
+ },
76
+ sendImage: async (jid, buffer, opts) => {
77
+ const id = `msg-${++msgSeq}`;
78
+ calls.sentImages.push({ jid, buffer, opts });
79
+ const ref = { id, chatId: jid, timestamp: Date.now() };
80
+ sentHistory.push({
81
+ id,
82
+ chatId: jid,
83
+ fromMe: true,
84
+ type: "image",
85
+ contentHash: "hash-" + id,
86
+ timestamp: Date.now(),
87
+ });
88
+ return ref;
89
+ },
90
+ sendVideo: async (jid, buffer, opts) => {
91
+ const id = `msg-${++msgSeq}`;
92
+ calls.sentVideos.push({ jid, buffer, opts });
93
+ const ref = { id, chatId: jid, timestamp: Date.now() };
94
+ sentHistory.push({
95
+ id,
96
+ chatId: jid,
97
+ fromMe: true,
98
+ type: "video",
99
+ contentHash: "hash-" + id,
100
+ timestamp: Date.now(),
101
+ });
102
+ return ref;
103
+ },
104
+ sendAudio: async (jid, buffer, opts) => {
105
+ const id = `msg-${++msgSeq}`;
106
+ calls.sentAudios.push({ jid, buffer, opts });
107
+ const ref = { id, chatId: jid, timestamp: Date.now() };
108
+ sentHistory.push({
109
+ id,
110
+ chatId: jid,
111
+ fromMe: true,
112
+ type: "audio",
113
+ contentHash: "hash-" + id,
114
+ timestamp: Date.now(),
115
+ });
116
+ return ref;
117
+ },
118
+ sendSticker: async (jid, buffer, opts) => {
119
+ const id = `msg-${++msgSeq}`;
120
+ calls.sentStickers.push({ jid, buffer, opts });
121
+ const ref = { id, chatId: jid, timestamp: Date.now() };
122
+ sentHistory.push({
123
+ id,
124
+ chatId: jid,
125
+ fromMe: true,
126
+ type: "sticker",
127
+ contentHash: "hash-" + id,
128
+ timestamp: Date.now(),
129
+ });
130
+ return ref;
131
+ },
132
+ sendDocument: async (jid, buffer, filename, mimetype, opts) => {
133
+ const id = `msg-${++msgSeq}`;
134
+ calls.sentDocuments.push({ jid, buffer, filename, mimetype, opts });
135
+ const ref = { id, chatId: jid, timestamp: Date.now() };
136
+ sentHistory.push({
137
+ id,
138
+ chatId: jid,
139
+ fromMe: true,
140
+ type: "document",
141
+ contentHash: "hash-" + id,
142
+ timestamp: Date.now(),
143
+ });
144
+ return ref;
145
+ },
146
+ sendPoll: async (jid, opts) => {
147
+ const id = `msg-${++msgSeq}`;
148
+ calls.sentPolls.push({ jid, opts });
149
+ const ref = { id, chatId: jid, timestamp: Date.now() };
150
+ sentHistory.push({
151
+ id,
152
+ chatId: jid,
153
+ fromMe: true,
154
+ type: "other",
155
+ contentHash: "hash-" + id,
156
+ timestamp: Date.now(),
157
+ });
158
+ return ref;
159
+ },
160
+ react: async (jid, target, emoji) => {
161
+ calls.reactions.push({ jid, target, emoji });
162
+ },
163
+ deleteMessage: async (jid, target, forEveryone) => {
164
+ calls.deletes.push({ jid, target, forEveryone });
165
+ },
166
+ editMessage: async (jid, target, text) => {
167
+ calls.edits.push({ jid, target, text });
168
+ },
169
+ sendPresenceUpdate: async () => { },
170
+ readMessages: async () => { },
171
+ onWhatsApp: async (jid) => (jid.includes("999") || jid.includes("888") || jid.includes("777") ? [{ exists: true }] : [{ exists: false }]),
172
+ getBusinessProfile: async (jid) => (jid.includes("business") ? { description: "Test Business" } : null),
173
+ profilePictureUrl: async (jid) => (jid.includes("with-pfp") ? "https://example.com/avatar.jpg" : null),
174
+ fetchStatus: async (jid) => (jid.includes("with-status") ? "Available for testing" : null),
175
+ updateBlockStatus: async (jid, action) => {
176
+ calls.blockUpdates.push({ jid, action });
177
+ },
178
+ addOrEditContact: async () => { },
179
+ removeContact: async () => { },
180
+ groupMetadata: async (jid) => ({
181
+ subject: "Test Mock Group",
182
+ participants: [
183
+ { id: "5516999999999@s.whatsapp.net", isAdmin: true, isSuperAdmin: false },
184
+ { id: "5516888888888@s.whatsapp.net", isAdmin: true, isSuperAdmin: true },
185
+ { id: "5516777777777@s.whatsapp.net", isAdmin: false, isSuperAdmin: false },
186
+ ],
187
+ }),
188
+ groupParticipantsUpdate: async (jid, users, action) => {
189
+ calls.groupParticipantUpdates.push({ jid, users, action });
190
+ return users.map((u) => ({ status: "200", jid: u }));
191
+ },
192
+ groupUpdateSubject: async () => { },
193
+ groupUpdateDescription: async () => { },
194
+ groupInviteCode: async () => "mock-invite-code-123",
195
+ groupRevokeInvite: async () => "mock-new-invite-code-456",
196
+ updateProfilePicture: async () => { },
197
+ updateProfileName: async (name) => {
198
+ calls.nameUpdates.push(name);
199
+ },
200
+ updateProfileStatus: async (status) => {
201
+ calls.statusUpdates.push(status);
202
+ },
203
+ me: () => ({ id: "5516999999999@s.whatsapp.net", lid: "99999@lid" }),
204
+ downloadMedia: async () => ({ mimetype: "image/jpeg", data: Buffer.from("fake-image-bytes") }),
205
+ getHistory: async (jid) => sentHistory.filter((m) => m.chatId === jid),
206
+ };
207
+ contract[RAW_SOCK_SYM] = rawMockSock;
208
+ return { contract, calls };
209
+ }
210
+ function makeBotMessage(overrides = {}) {
211
+ return {
212
+ id: "msg-test-100",
213
+ chatId: "120363000000000@g.us",
214
+ fromMe: false,
215
+ contentHash: "mock-content-hash-123",
216
+ timestamp: 1700000000,
217
+ type: "text",
218
+ body: "!ping test arg",
219
+ participantAlt: "5516777777777@s.whatsapp.net",
220
+ fromPn: "5516777777777@s.whatsapp.net",
221
+ fromLid: "12345@lid",
222
+ ...overrides,
223
+ };
224
+ }
225
+ describe("kernel/pluginApi — storage facet", () => {
226
+ test("buildStorageApi creates isolated dir and enforces path sandbox", () => {
227
+ const storage = buildStorageApi("test_plugin");
228
+ assert.ok(storage.dir.includes("test_plugin"));
229
+ const safePath = storage.resolve("subdir/data.json");
230
+ assert.ok(safePath.startsWith(storage.dir));
231
+ assert.throws(() => storage.resolve("../escape.txt"), /path traversal/);
232
+ assert.throws(() => storage.resolve("/absolute/path"), /absolute paths are not allowed/);
233
+ assert.throws(() => storage.resolve("folder\\windows"), /Windows-style paths are not allowed/);
234
+ assert.throws(() => storage.resolve(""), /non-empty string/);
235
+ });
236
+ });
237
+ describe("kernel/pluginApi — buildSetupApi with Mock WaContract", () => {
238
+ let store;
239
+ let pluginRegistry;
240
+ let mockContract;
241
+ let calls;
242
+ beforeEach(() => {
243
+ _resetDriverManagerForTests();
244
+ store = createStore();
245
+ pluginRegistry = new Map();
246
+ const mock = createMockContract();
247
+ mockContract = mock.contract;
248
+ calls = mock.calls;
249
+ getDriverManager().register(mockContract, { isPrimary: true });
250
+ });
251
+ afterEach(() => {
252
+ cleanupPluginEvents("test_plugin", mockContract);
253
+ _resetDriverManagerForTests();
254
+ });
255
+ test("exposes setup surface and base facets", async () => {
256
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
257
+ // Base facets
258
+ assert.ok(ctx.log);
259
+ assert.equal(typeof ctx.log.info, "function");
260
+ assert.equal(typeof ctx.t, "function");
261
+ assert.ok(ctx.config);
262
+ assert.ok(ctx.i18n);
263
+ assert.ok(ctx.utils);
264
+ assert.ok(ctx.download);
265
+ assert.ok(ctx.scheduler);
266
+ assert.ok(ctx.plugins);
267
+ assert.ok(ctx.chats);
268
+ assert.ok(ctx.contacts);
269
+ assert.ok(ctx.storage);
270
+ assert.equal(ctx.botId, "99999@lid");
271
+ // Setup send only has .to()
272
+ assert.ok(ctx.send.to);
273
+ assert.equal(typeof ctx.send.to, "function");
274
+ // Admin requires explicit .to()
275
+ assert.ok(ctx.admin);
276
+ assert.equal(typeof ctx.admin.add, "function");
277
+ // Me API
278
+ assert.ok(ctx.me);
279
+ await ctx.me.setName("New Bot Name");
280
+ assert.deepEqual(calls.nameUpdates, ["New Bot Name"]);
281
+ await ctx.me.setAbout("New About Text");
282
+ assert.deepEqual(calls.statusUpdates, ["New About Text"]);
283
+ // Events API
284
+ let eventPayload = null;
285
+ const unsub = ctx.events.on("messages.upsert", (payload) => {
286
+ eventPayload = payload;
287
+ });
288
+ assert.equal(typeof unsub, "function");
289
+ });
290
+ test("setup send.to() sends messages via WaContract", async () => {
291
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
292
+ await ctx.send.to("5516777777777@s.whatsapp.net").text("Hello from setup");
293
+ assert.equal(calls.sentTexts.length, 1);
294
+ assert.equal(calls.sentTexts[0].jid, "5516777777777@s.whatsapp.net");
295
+ assert.equal(calls.sentTexts[0].text, "Hello from setup");
296
+ });
297
+ test("setup admin.add().to() executes group member addition", async () => {
298
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
299
+ await ctx.admin.add("5516777777777@s.whatsapp.net").to("120363000000000@g.us");
300
+ assert.equal(calls.groupParticipantUpdates.length, 1);
301
+ assert.equal(calls.groupParticipantUpdates[0].action, "add");
302
+ assert.equal(calls.groupParticipantUpdates[0].jid, "120363000000000@g.us");
303
+ });
304
+ });
305
+ describe("kernel/pluginApi — buildApi (Runtime) with Mock WaContract", () => {
306
+ let store;
307
+ let pluginRegistry;
308
+ let mockContract;
309
+ let calls;
310
+ beforeEach(() => {
311
+ _resetDriverManagerForTests();
312
+ store = createStore();
313
+ pluginRegistry = new Map();
314
+ const mock = createMockContract();
315
+ mockContract = mock.contract;
316
+ calls = mock.calls;
317
+ getDriverManager().register(mockContract, { isPrimary: true });
318
+ });
319
+ afterEach(() => {
320
+ cleanupPluginEvents("test_plugin", mockContract);
321
+ _resetDriverManagerForTests();
322
+ __resetSessionsForTests();
323
+ });
324
+ test("buildApi provides full runtime context and resolves group admin checks", async () => {
325
+ const msg = makeBotMessage();
326
+ const chat = {
327
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
328
+ name: "Test Group",
329
+ isGroup: true,
330
+ };
331
+ const ctx = buildApi({
332
+ msg,
333
+ chat,
334
+ contract: mockContract,
335
+ store,
336
+ pluginRegistry,
337
+ pluginName: "test_plugin",
338
+ guardOptions: { cooldown: false, jitter: false },
339
+ });
340
+ // Chat properties & helpers
341
+ assert.equal(ctx.chat.isGroup, true);
342
+ assert.equal(ctx.chat.name, "Test Group");
343
+ const participants = await ctx.chat.getParticipants();
344
+ assert.equal(participants.length, 3);
345
+ assert.equal(participants[0].isAdmin, true);
346
+ const isSenderAdmin = await ctx.chat.isSenderAdmin();
347
+ assert.equal(isSenderAdmin, false); // 5516777777777 is not admin in mock
348
+ const isBotAdmin = await ctx.chat.isBotAdmin();
349
+ assert.equal(isBotAdmin, true); // 5516999999999 is admin in mock
350
+ // Send methods
351
+ await ctx.send.text("Test response");
352
+ assert.equal(calls.sentTexts.length, 1);
353
+ assert.equal(calls.sentTexts[0].text, "Test response");
354
+ await ctx.send.image(Buffer.from("image-data"), "Caption test");
355
+ assert.equal(calls.sentImages.length, 1);
356
+ assert.equal(calls.sentImages[0].opts && calls.sentImages[0].opts.caption, "Caption test");
357
+ await ctx.send.audio(Buffer.from("audio-data"));
358
+ assert.equal(calls.sentAudios.length, 1);
359
+ await ctx.send.sticker(Buffer.from("sticker-data"));
360
+ assert.equal(calls.sentStickers.length, 1);
361
+ await ctx.send.file(Buffer.from("file-data"), "test.pdf");
362
+ assert.equal(calls.sentDocuments.length, 1);
363
+ // Message reply helper
364
+ await ctx.msg.reply.text("Replying to message");
365
+ assert.equal(calls.sentTexts.length, 2);
366
+ assert.equal(calls.sentTexts[1].text, "Replying to message");
367
+ // Admin methods in bound chat
368
+ await ctx.admin.promote("5516777777777@s.whatsapp.net");
369
+ assert.equal(calls.groupParticipantUpdates.length, 1);
370
+ assert.equal(calls.groupParticipantUpdates[0].action, "promote");
371
+ await ctx.admin.kick("5516777777777@s.whatsapp.net");
372
+ assert.equal(calls.groupParticipantUpdates.length, 2);
373
+ assert.equal(calls.groupParticipantUpdates[1].action, "remove");
374
+ await assert.rejects(() => ctx.admin.kick("5516999999999@s.whatsapp.net"));
375
+ assert.equal(calls.groupParticipantUpdates.length, 2);
376
+ const inviteLink = await ctx.admin.getInviteLink();
377
+ assert.match(inviteLink, /chat\.whatsapp\.com\/mock-invite-code-123/);
378
+ // Contacts helper
379
+ const contact = await ctx.contacts.get("5516999999999@s.whatsapp.net");
380
+ assert.ok(contact);
381
+ // normalizeContact now returns E.164 (with leading "+") per the
382
+ // new contract invariant; assert accordingly.
383
+ assert.equal(contact?.number, "+5516999999999");
384
+ assert.equal(contact?.isMe, true);
385
+ // Looking up the bot by its own LID (ctx.botId) must resolve the
386
+ // same identity — number populated via the proactively-learned
387
+ // LID↔PN mapping, not left null for lack of a resolveLid() hit.
388
+ const selfByLid = await ctx.contacts.get(ctx.botId);
389
+ assert.ok(selfByLid);
390
+ assert.equal(selfByLid?.id, "99999@lid");
391
+ assert.equal(selfByLid?.number, "+5516999999999");
392
+ assert.equal(selfByLid?.isMe, true);
393
+ await ctx.contacts.block("5516777777777@s.whatsapp.net");
394
+ assert.equal(calls.blockUpdates.length, 1);
395
+ assert.equal(calls.blockUpdates[0].action, "block");
396
+ // Platform escape hatch
397
+ assert.ok(ctx.wa);
398
+ assert.equal(ctx.wa?.contract, mockContract);
399
+ assert.equal(ctx.tg, null);
400
+ assert.equal(ctx.dc, null);
401
+ const mediaResult = await ctx.wa?.downloadMedia();
402
+ assert.ok(mediaResult?.data);
403
+ });
404
+ test("settings, poll, and unblock facets work correctly", async () => {
405
+ const msg = makeBotMessage();
406
+ const chat = {
407
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
408
+ name: "Test Group",
409
+ isGroup: true,
410
+ };
411
+ const ctx = buildApi({
412
+ msg,
413
+ chat,
414
+ contract: mockContract,
415
+ store,
416
+ pluginRegistry,
417
+ pluginName: "test_plugin",
418
+ guardOptions: { cooldown: false, jitter: false },
419
+ });
420
+ // Settings API
421
+ assert.ok(ctx.settings);
422
+ ctx.settings.global.set("key1", "value1");
423
+ assert.equal(ctx.settings.global.get("key1"), "value1");
424
+ ctx.settings.forChat("chat123").set("chatKey", "chatValue");
425
+ assert.equal(ctx.settings.forChat("chat123").get("chatKey"), "chatValue");
426
+ // Unblock contact
427
+ await ctx.contacts.unblock("5516777777777@s.whatsapp.net");
428
+ assert.equal(calls.blockUpdates.length, 1);
429
+ assert.equal(calls.blockUpdates[0].action, "unblock");
430
+ // Send video
431
+ await ctx.send.video(Buffer.from("video-data"), "Video caption");
432
+ assert.equal(calls.sentVideos.length, 1);
433
+ // Poll API
434
+ assert.ok(ctx.poll);
435
+ assert.equal(typeof ctx.poll.create, "function");
436
+ // TargetableAction thenable (.then directly)
437
+ let directThenCalled = false;
438
+ await ctx.send.text("Direct thenable").then(() => {
439
+ directThenCalled = true;
440
+ });
441
+ assert.equal(directThenCalled, true);
442
+ });
443
+ test("ctx.session enforces one exclusive lock per chat across plugins (Phase 7)", async () => {
444
+ const msg = makeBotMessage();
445
+ const chat = {
446
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
447
+ name: "Test Group",
448
+ isGroup: true,
449
+ };
450
+ const gameCtx = buildApi({
451
+ msg, chat, contract: mockContract, store, pluginRegistry,
452
+ pluginName: "gamePlugin",
453
+ guardOptions: { cooldown: false, jitter: false },
454
+ });
455
+ const figurinhaCtx = buildApi({
456
+ msg, chat, contract: mockContract, store, pluginRegistry,
457
+ pluginName: "figurinhaPlugin",
458
+ guardOptions: { cooldown: false, jitter: false },
459
+ });
460
+ // Free chat: the first plugin to ask gets the lock.
461
+ assert.equal(gameCtx.session.isLocked(), false);
462
+ assert.equal(gameCtx.session.acquire(), true);
463
+ assert.equal(gameCtx.session.isMine(), true);
464
+ assert.equal(gameCtx.session.isLocked(), true);
465
+ // A second plugin in the SAME chat cannot also open a session.
466
+ assert.equal(figurinhaCtx.session.isLocked(), true);
467
+ assert.equal(figurinhaCtx.session.acquire(), false);
468
+ assert.equal(figurinhaCtx.session.isMine(), false);
469
+ // The holder re-acquiring its own session is a harmless no-op.
470
+ assert.equal(gameCtx.session.acquire(), true);
471
+ // The non-holder cannot release someone else's session.
472
+ figurinhaCtx.session.release();
473
+ assert.equal(gameCtx.session.isLocked(), true, "release from a non-holder must not affect the lock");
474
+ // Once the real holder releases it, another plugin can acquire it.
475
+ gameCtx.session.release();
476
+ assert.equal(gameCtx.session.isLocked(), false);
477
+ assert.equal(figurinhaCtx.session.acquire(), true);
478
+ assert.equal(figurinhaCtx.session.isMine(), true);
479
+ });
480
+ test("events.once and cleanup removes listeners", async () => {
481
+ let triggeredCount = 0;
482
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_events_plugin");
483
+ // Test once
484
+ void ctx.events.once("messages.upsert").then(() => {
485
+ triggeredCount++;
486
+ });
487
+ // Clean up
488
+ cleanupPluginEvents("test_events_plugin", mockContract);
489
+ assert.equal(typeof ctx.events.cleanup, "function");
490
+ });
491
+ test("config, i18n, download, scheduler, plugins, chats, contacts pfp/about facets", async () => {
492
+ const msg = makeBotMessage();
493
+ const chat = {
494
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
495
+ name: "Test Group",
496
+ isGroup: true,
497
+ };
498
+ // Register a dependency plugin so plugins.get/require/exists have something real to resolve.
499
+ pluginRegistry.set("dep_plugin", {
500
+ name: "dep_plugin",
501
+ status: "active",
502
+ manifest: { name: "dep_plugin", version: "1.0.0" },
503
+ exports: { greet: () => "hi" },
504
+ });
505
+ store.hydrate({
506
+ chats: [{ id: "120363000000000@g.us", name: "Test Group", ephemeralExpiration: 0 }],
507
+ contacts: {},
508
+ lidMap: [],
509
+ });
510
+ const ctx = buildApi({
511
+ msg,
512
+ chat,
513
+ contract: mockContract,
514
+ store,
515
+ pluginRegistry,
516
+ pluginName: "test_plugin",
517
+ guardOptions: { cooldown: false, jitter: false },
518
+ });
519
+ // config.get
520
+ assert.equal(ctx.config.get("__no_such_key__", "fallback"), "fallback");
521
+ // i18n.t — unknown key still returns a string, never throws
522
+ assert.equal(typeof ctx.i18n.t("__no_such_key__"), "string");
523
+ assert.equal(typeof ctx.t("__no_such_key__"), "string");
524
+ // download.enqueue runs the work function
525
+ let downloadRan = false;
526
+ await new Promise((resolve) => {
527
+ ctx.download.enqueue(async () => {
528
+ downloadRan = true;
529
+ resolve();
530
+ }, async () => resolve());
531
+ });
532
+ assert.equal(downloadRan, true);
533
+ // scheduler.schedule returns a handle with stop()
534
+ const handle = ctx.scheduler.schedule("0 9 * * 1", async () => { });
535
+ assert.equal(typeof handle.stop, "function");
536
+ handle.stop();
537
+ // plugins.get / require / exists
538
+ assert.ok(ctx.plugins.exists("dep_plugin"));
539
+ assert.equal(ctx.plugins.exists("missing_plugin"), false);
540
+ assert.equal(ctx.plugins.get("dep_plugin").greet(), "hi");
541
+ assert.equal(ctx.plugins.get("missing_plugin"), null);
542
+ assert.equal(ctx.plugins.require("dep_plugin").greet(), "hi");
543
+ assert.throws(() => ctx.plugins.require("missing_plugin"), /does not exist or is not active/);
544
+ // chats.all
545
+ const allChats = ctx.chats.all();
546
+ assert.equal(allChats.length, 1);
547
+ assert.equal(allChats[0].name, "Test Group");
548
+ assert.equal(allChats[0].isGroup, true);
549
+ // contacts pfp/about — no pfp/status configured for this jid
550
+ assert.equal(await ctx.contacts.getPfpUrl("5516777777777@s.whatsapp.net"), null);
551
+ assert.equal(await ctx.contacts.getPfpPath("5516777777777@s.whatsapp.net", "/tmp/x.jpg"), null);
552
+ assert.equal(await ctx.contacts.getAbout("5516777777777@s.whatsapp.net"), null);
553
+ // contacts pfp — jid the mock contract recognizes
554
+ assert.equal(await ctx.contacts.getPfpUrl("5516999999999-with-pfp@s.whatsapp.net"), "https://example.com/avatar.jpg");
555
+ // NOTE: WaContract.fetchStatus is typed Promise<string|null> (the
556
+ // Baileys adapter already unwraps the array/object USync shapes down
557
+ // to a plain string before returning). getAbout()'s array/object
558
+ // branches are therefore currently unreachable through the contract —
559
+ // any contract-conformant fetchStatus always lands on the `null`
560
+ // fallback here. Flagged in TEST_REVIEW.md rather than silently
561
+ // asserting a value that can't happen in practice.
562
+ assert.equal(await ctx.contacts.getAbout("5516999999999-with-status@s.whatsapp.net"), null);
563
+ });
564
+ test("admin demote/setSubject/setDescription/setProfilePic/revokeInvite, send.gif/poll, contacts.get resolves via contract.resolveLid", async () => {
565
+ const msg = makeBotMessage();
566
+ const chat = {
567
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
568
+ name: "Test Group",
569
+ isGroup: true,
570
+ };
571
+ const ctx = buildApi({
572
+ msg,
573
+ chat,
574
+ contract: mockContract,
575
+ store,
576
+ pluginRegistry,
577
+ pluginName: "test_plugin",
578
+ guardOptions: { cooldown: false, jitter: false },
579
+ });
580
+ await ctx.admin.demote("5516777777777@s.whatsapp.net");
581
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.action, "demote");
582
+ await ctx.admin.setSubject("New Subject");
583
+ await ctx.admin.setDescription("New Description");
584
+ await ctx.admin.setProfilePic(Buffer.from("pic-data"));
585
+ const invite = await ctx.admin.revokeInvite();
586
+ assert.match(String(invite), /mock-new-invite-code-456/);
587
+ await ctx.send.gif(Buffer.from("already-mp4-bytes"), "gif caption");
588
+ await ctx.send.poll("Favorite color?", ["Red", "Blue"]);
589
+ assert.equal(calls.sentPolls.length, 1);
590
+ // contacts.get() with a raw @lid routes through contract.resolveLid()
591
+ // (mock: "12345@lid" -> "5516777777777@s.whatsapp.net").
592
+ const contact = await ctx.contacts.get("12345@lid");
593
+ // normalizeContact now keeps the ID as the LID form ("12345@lid")
594
+ // when known, per the invariant that user IDs are LID-or-null; plugins
595
+ // should treat contact.id as the canonical JID for addressing and
596
+ // contact.number/contact.numberRaw as the dialable string.
597
+ assert.equal(contact?.id, "12345@lid");
598
+ assert.equal(contact?.number, "+5516777777777");
599
+ });
600
+ });
@@ -36,22 +36,22 @@ function withTimeout(promise, ms, pluginName) {
36
36
  });
37
37
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
38
38
  }
39
- /**
40
- * @param {object} plugin — pluginRegistry entry
41
- * @param {object} context — buildApi ctx
42
- *
43
- * plugin.guardOptions (optional, read from plugin's own export):
44
- * @param {boolean} [plugin.guardOptions.timeout=true]
45
- */
46
- export async function runPlugin(plugin, context) {
39
+ export async function runPlugin(plugin, context, handler, input, options) {
47
40
  if (plugin.status !== "active")
48
- return;
41
+ return undefined;
49
42
  const useTimeout = plugin.guardOptions?.timeout !== false;
50
43
  try {
51
- if (!plugin.run)
52
- return;
53
- const run = plugin.run(context);
54
- await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
44
+ if (handler) {
45
+ const run = handler(context, input);
46
+ return await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
47
+ }
48
+ else {
49
+ if (!plugin.run)
50
+ return undefined;
51
+ const run = plugin.run(context);
52
+ await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
53
+ return undefined;
54
+ }
55
55
  }
56
56
  catch (e) {
57
57
  const error = e instanceof Error ? e : new Error(String(e));
@@ -68,6 +68,8 @@ export async function runPlugin(plugin, context) {
68
68
  const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
69
69
  logger.error(` at : ${frame}`);
70
70
  }
71
+ if (options?.rethrow)
72
+ throw error;
71
73
  }
72
74
  else {
73
75
  pluginRegistry.set(plugin.name, plugin);
@@ -77,6 +79,8 @@ export async function runPlugin(plugin, context) {
77
79
  const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
78
80
  logger.warn(` at : ${frame}`);
79
81
  }
82
+ if (options?.rethrow)
83
+ throw error;
80
84
  // Reload the plugin dynamically to avoid circular dependency
81
85
  import("#kernel/pluginLoader.js").then(({ reloadPlugin }) => {
82
86
  reloadPlugin(plugin.name).catch(err => {
@@ -84,5 +88,6 @@ export async function runPlugin(plugin, context) {
84
88
  });
85
89
  });
86
90
  }
91
+ return undefined;
87
92
  }
88
93
  }