@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,67 @@
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-integration-loader-"));
7
+ process.env.MANYBOT_CONFIG_DIR = configDir;
8
+ const { loadIntegrationPlugin, pluginRegistry, cleanupPlugins, } = await import("#kernel/pluginLoader.js");
9
+ const { INTEGRATION_PLUGIN_NAME } = await import("#kernel/integrationMode.js");
10
+ const ORIGINAL_OPT_IN = process.env.MANYBOT_RUN_WHATSAPP_TESTS;
11
+ beforeEach(async () => {
12
+ await cleanupPlugins();
13
+ pluginRegistry.clear();
14
+ // Force-clear and re-apply the opt-in between tests; each test sets
15
+ // it explicitly so we know what the contract looks like.
16
+ delete process.env.MANYBOT_RUN_WHATSAPP_TESTS;
17
+ });
18
+ after(async () => {
19
+ await cleanupPlugins();
20
+ pluginRegistry.clear();
21
+ if (ORIGINAL_OPT_IN === undefined)
22
+ delete process.env.MANYBOT_RUN_WHATSAPP_TESTS;
23
+ else
24
+ process.env.MANYBOT_RUN_WHATSAPP_TESTS = ORIGINAL_OPT_IN;
25
+ await fs.rm(configDir, { recursive: true, force: true });
26
+ });
27
+ describe("kernel/pluginLoader — loadIntegrationPlugin", () => {
28
+ test("refuses to load without the opt-in flag", async () => {
29
+ // opt-in explicitly NOT set
30
+ await assert.rejects(loadIntegrationPlugin(), /MANYBOT_RUN_WHATSAPP_TESTS=1/);
31
+ assert.equal(pluginRegistry.has(INTEGRATION_PLUGIN_NAME), false);
32
+ });
33
+ test("registers the integration plugin when opt-in is set", async () => {
34
+ process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
35
+ const entry = await loadIntegrationPlugin();
36
+ assert.equal(entry.name, INTEGRATION_PLUGIN_NAME);
37
+ assert.equal(entry.status, "active");
38
+ assert.equal(typeof entry.run, "function");
39
+ assert.equal(entry.commands, null, "integration plugin must not register user commands");
40
+ assert.ok(pluginRegistry.has(INTEGRATION_PLUGIN_NAME));
41
+ });
42
+ test("is idempotent — second call returns the same entry without re-importing", async () => {
43
+ process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
44
+ const first = await loadIntegrationPlugin();
45
+ const second = await loadIntegrationPlugin();
46
+ assert.equal(first, second);
47
+ // Single registration, no duplicate.
48
+ assert.equal(pluginRegistry.size, 1);
49
+ });
50
+ test("exports a public API object via plugin.exports", async () => {
51
+ process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
52
+ const entry = await loadIntegrationPlugin();
53
+ const api = entry.exports;
54
+ assert.ok(api, "integration plugin must expose a public API");
55
+ assert.equal(typeof api.isTestChat, "function");
56
+ assert.equal(typeof api.waitForMarker, "function");
57
+ assert.equal(typeof api.recentBodies, "function");
58
+ // testChat is populated in setup(), not on registration.
59
+ assert.equal(api.testChat, "");
60
+ });
61
+ test("loads the plugin from the source path shipped with the repo", async () => {
62
+ process.env.MANYBOT_RUN_WHATSAPP_TESTS = "1";
63
+ const entry = await loadIntegrationPlugin();
64
+ // The plugin must be the real one (default export present).
65
+ assert.equal(typeof entry.run, "function");
66
+ });
67
+ });
@@ -0,0 +1,583 @@
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, "5516999999999@s.whatsapp.net");
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
+ const inviteLink = await ctx.admin.getInviteLink();
375
+ assert.match(inviteLink, /chat\.whatsapp\.com\/mock-invite-code-123/);
376
+ // Contacts helper
377
+ const contact = await ctx.contacts.get("5516999999999@s.whatsapp.net");
378
+ assert.ok(contact);
379
+ assert.equal(contact?.number, "5516999999999");
380
+ assert.equal(contact?.isMe, true);
381
+ await ctx.contacts.block("5516777777777@s.whatsapp.net");
382
+ assert.equal(calls.blockUpdates.length, 1);
383
+ assert.equal(calls.blockUpdates[0].action, "block");
384
+ // Platform escape hatch
385
+ assert.ok(ctx.wa);
386
+ assert.equal(ctx.wa?.contract, mockContract);
387
+ assert.equal(ctx.tg, null);
388
+ assert.equal(ctx.dc, null);
389
+ const mediaResult = await ctx.wa?.downloadMedia();
390
+ assert.ok(mediaResult?.data);
391
+ });
392
+ test("settings, poll, and unblock facets work correctly", async () => {
393
+ const msg = makeBotMessage();
394
+ const chat = {
395
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
396
+ name: "Test Group",
397
+ isGroup: true,
398
+ };
399
+ const ctx = buildApi({
400
+ msg,
401
+ chat,
402
+ contract: mockContract,
403
+ store,
404
+ pluginRegistry,
405
+ pluginName: "test_plugin",
406
+ guardOptions: { cooldown: false, jitter: false },
407
+ });
408
+ // Settings API
409
+ assert.ok(ctx.settings);
410
+ ctx.settings.global.set("key1", "value1");
411
+ assert.equal(ctx.settings.global.get("key1"), "value1");
412
+ ctx.settings.forChat("chat123").set("chatKey", "chatValue");
413
+ assert.equal(ctx.settings.forChat("chat123").get("chatKey"), "chatValue");
414
+ // Unblock contact
415
+ await ctx.contacts.unblock("5516777777777@s.whatsapp.net");
416
+ assert.equal(calls.blockUpdates.length, 1);
417
+ assert.equal(calls.blockUpdates[0].action, "unblock");
418
+ // Send video
419
+ await ctx.send.video(Buffer.from("video-data"), "Video caption");
420
+ assert.equal(calls.sentVideos.length, 1);
421
+ // Poll API
422
+ assert.ok(ctx.poll);
423
+ assert.equal(typeof ctx.poll.create, "function");
424
+ // TargetableAction thenable (.then directly)
425
+ let directThenCalled = false;
426
+ await ctx.send.text("Direct thenable").then(() => {
427
+ directThenCalled = true;
428
+ });
429
+ assert.equal(directThenCalled, true);
430
+ });
431
+ test("ctx.session enforces one exclusive lock per chat across plugins (Phase 7)", async () => {
432
+ const msg = makeBotMessage();
433
+ const chat = {
434
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
435
+ name: "Test Group",
436
+ isGroup: true,
437
+ };
438
+ const gameCtx = buildApi({
439
+ msg, chat, contract: mockContract, store, pluginRegistry,
440
+ pluginName: "gamePlugin",
441
+ guardOptions: { cooldown: false, jitter: false },
442
+ });
443
+ const figurinhaCtx = buildApi({
444
+ msg, chat, contract: mockContract, store, pluginRegistry,
445
+ pluginName: "figurinhaPlugin",
446
+ guardOptions: { cooldown: false, jitter: false },
447
+ });
448
+ // Free chat: the first plugin to ask gets the lock.
449
+ assert.equal(gameCtx.session.isLocked(), false);
450
+ assert.equal(gameCtx.session.acquire(), true);
451
+ assert.equal(gameCtx.session.isMine(), true);
452
+ assert.equal(gameCtx.session.isLocked(), true);
453
+ // A second plugin in the SAME chat cannot also open a session.
454
+ assert.equal(figurinhaCtx.session.isLocked(), true);
455
+ assert.equal(figurinhaCtx.session.acquire(), false);
456
+ assert.equal(figurinhaCtx.session.isMine(), false);
457
+ // The holder re-acquiring its own session is a harmless no-op.
458
+ assert.equal(gameCtx.session.acquire(), true);
459
+ // The non-holder cannot release someone else's session.
460
+ figurinhaCtx.session.release();
461
+ assert.equal(gameCtx.session.isLocked(), true, "release from a non-holder must not affect the lock");
462
+ // Once the real holder releases it, another plugin can acquire it.
463
+ gameCtx.session.release();
464
+ assert.equal(gameCtx.session.isLocked(), false);
465
+ assert.equal(figurinhaCtx.session.acquire(), true);
466
+ assert.equal(figurinhaCtx.session.isMine(), true);
467
+ });
468
+ test("events.once and cleanup removes listeners", async () => {
469
+ let triggeredCount = 0;
470
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_events_plugin");
471
+ // Test once
472
+ void ctx.events.once("messages.upsert").then(() => {
473
+ triggeredCount++;
474
+ });
475
+ // Clean up
476
+ cleanupPluginEvents("test_events_plugin", mockContract);
477
+ assert.equal(typeof ctx.events.cleanup, "function");
478
+ });
479
+ test("config, i18n, download, scheduler, plugins, chats, contacts pfp/about facets", async () => {
480
+ const msg = makeBotMessage();
481
+ const chat = {
482
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
483
+ name: "Test Group",
484
+ isGroup: true,
485
+ };
486
+ // Register a dependency plugin so plugins.get/require/exists have something real to resolve.
487
+ pluginRegistry.set("dep_plugin", {
488
+ name: "dep_plugin",
489
+ status: "active",
490
+ manifest: { name: "dep_plugin", version: "1.0.0" },
491
+ exports: { greet: () => "hi" },
492
+ });
493
+ store.hydrate({
494
+ chats: [{ id: "120363000000000@g.us", name: "Test Group", ephemeralExpiration: 0 }],
495
+ contacts: {},
496
+ lidMap: [],
497
+ });
498
+ const ctx = buildApi({
499
+ msg,
500
+ chat,
501
+ contract: mockContract,
502
+ store,
503
+ pluginRegistry,
504
+ pluginName: "test_plugin",
505
+ guardOptions: { cooldown: false, jitter: false },
506
+ });
507
+ // config.get
508
+ assert.equal(ctx.config.get("__no_such_key__", "fallback"), "fallback");
509
+ // i18n.t — unknown key still returns a string, never throws
510
+ assert.equal(typeof ctx.i18n.t("__no_such_key__"), "string");
511
+ assert.equal(typeof ctx.t("__no_such_key__"), "string");
512
+ // download.enqueue runs the work function
513
+ let downloadRan = false;
514
+ await new Promise((resolve) => {
515
+ ctx.download.enqueue(async () => {
516
+ downloadRan = true;
517
+ resolve();
518
+ }, async () => resolve());
519
+ });
520
+ assert.equal(downloadRan, true);
521
+ // scheduler.schedule returns a handle with stop()
522
+ const handle = ctx.scheduler.schedule("0 9 * * 1", async () => { });
523
+ assert.equal(typeof handle.stop, "function");
524
+ handle.stop();
525
+ // plugins.get / require / exists
526
+ assert.ok(ctx.plugins.exists("dep_plugin"));
527
+ assert.equal(ctx.plugins.exists("missing_plugin"), false);
528
+ assert.equal(ctx.plugins.get("dep_plugin").greet(), "hi");
529
+ assert.equal(ctx.plugins.get("missing_plugin"), null);
530
+ assert.equal(ctx.plugins.require("dep_plugin").greet(), "hi");
531
+ assert.throws(() => ctx.plugins.require("missing_plugin"), /does not exist or is not active/);
532
+ // chats.all
533
+ const allChats = ctx.chats.all();
534
+ assert.equal(allChats.length, 1);
535
+ assert.equal(allChats[0].name, "Test Group");
536
+ assert.equal(allChats[0].isGroup, true);
537
+ // contacts pfp/about — no pfp/status configured for this jid
538
+ assert.equal(await ctx.contacts.getPfpUrl("5516777777777@s.whatsapp.net"), null);
539
+ assert.equal(await ctx.contacts.getPfpPath("5516777777777@s.whatsapp.net", "/tmp/x.jpg"), null);
540
+ assert.equal(await ctx.contacts.getAbout("5516777777777@s.whatsapp.net"), null);
541
+ // contacts pfp — jid the mock contract recognizes
542
+ assert.equal(await ctx.contacts.getPfpUrl("5516999999999-with-pfp@s.whatsapp.net"), "https://example.com/avatar.jpg");
543
+ // NOTE: WaContract.fetchStatus is typed Promise<string|null> (the
544
+ // Baileys adapter already unwraps the array/object USync shapes down
545
+ // to a plain string before returning). getAbout()'s array/object
546
+ // branches are therefore currently unreachable through the contract —
547
+ // any contract-conformant fetchStatus always lands on the `null`
548
+ // fallback here. Flagged in TEST_REVIEW.md rather than silently
549
+ // asserting a value that can't happen in practice.
550
+ assert.equal(await ctx.contacts.getAbout("5516999999999-with-status@s.whatsapp.net"), null);
551
+ });
552
+ test("admin demote/setSubject/setDescription/setProfilePic/revokeInvite, send.gif/poll, contacts.get resolves via contract.resolveLid", async () => {
553
+ const msg = makeBotMessage();
554
+ const chat = {
555
+ id: { _serialized: "120363000000000@c.us", user: "120363000000000" },
556
+ name: "Test Group",
557
+ isGroup: true,
558
+ };
559
+ const ctx = buildApi({
560
+ msg,
561
+ chat,
562
+ contract: mockContract,
563
+ store,
564
+ pluginRegistry,
565
+ pluginName: "test_plugin",
566
+ guardOptions: { cooldown: false, jitter: false },
567
+ });
568
+ await ctx.admin.demote("5516777777777@s.whatsapp.net");
569
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.action, "demote");
570
+ await ctx.admin.setSubject("New Subject");
571
+ await ctx.admin.setDescription("New Description");
572
+ await ctx.admin.setProfilePic(Buffer.from("pic-data"));
573
+ const invite = await ctx.admin.revokeInvite();
574
+ assert.match(String(invite), /mock-new-invite-code-456/);
575
+ await ctx.send.gif(Buffer.from("already-mp4-bytes"), "gif caption");
576
+ await ctx.send.poll("Favorite color?", ["Red", "Blue"]);
577
+ assert.equal(calls.sentPolls.length, 1);
578
+ // contacts.get() with a raw @lid routes through contract.resolveLid()
579
+ // (mock: "12345@lid" -> "5516777777777@s.whatsapp.net").
580
+ const contact = await ctx.contacts.get("12345@lid");
581
+ assert.equal(contact?.id, "5516777777777@c.us");
582
+ });
583
+ });