@manybot/manybot 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  ---
14
14
 
15
- ManyBot is a free and open-source messaging automation ecosystem for online businesses and communities.
15
+ Open-source framework for message automation, extensible via community plugins.
16
16
 
17
17
  ## Requirements
18
18
 
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "4.3.0",
8
+ "version": "4.4.0",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {
@@ -24,6 +24,8 @@ import { Transform } from "node:stream";
24
24
  const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = path.dirname(__filename);
26
26
 
27
+ const DATA_PATH = path.join(CONFIG_DIR, "sessions");
28
+
27
29
  const chrome = path.join(CONFIG_DIR, "chrome/chrome")
28
30
 
29
31
  if (!fs.existsSync(chrome)) {
@@ -31,8 +33,15 @@ if (!fs.existsSync(chrome)) {
31
33
  logger.warn(t("errors.chromeNotFound"));
32
34
  const tmpDir = "/tmp/manybot-chrome";
33
35
  const tmpPath = path.join(tmpDir, "chrome.tar.gz");
34
- const url = "https://api.manybot.stxerr.dev/download-chrome";
36
+ const os = process.platform;
37
+ const urls = {
38
+ linux: "https://api.manybot.stxerr.dev/download-chrome-linux",
39
+ win32: "https://api.manybot.stxerr.dev/download-chrome-win"
40
+ }
35
41
 
42
+ const url = urls[os];
43
+ if (!url) throw new Error(t("errors.OSNotSupported", { os }));
44
+
36
45
  fs.mkdirSync(tmpDir, { recursive: true });
37
46
 
38
47
  const res = await fetch(url);
@@ -85,7 +94,10 @@ export const { Client, LocalAuth, MessageMedia } = pkg;
85
94
 
86
95
  // -- Instance --------------------------------------------------
87
96
  const clientOptions = {
88
- authStrategy: new LocalAuth({ clientId: CLIENT_ID }),
97
+ authStrategy: new LocalAuth({
98
+ clientId: CLIENT_ID,
99
+ dataPath: DATA_PATH
100
+ }),
89
101
  puppeteer: {
90
102
  executablePath: chrome,
91
103
  headless: true,
@@ -26,14 +26,10 @@ export async function handleMessage(msg) {
26
26
 
27
27
  if (CHATS.length > 0 && !CHATS.includes(chatId)) return;
28
28
 
29
- const ctx = buildApi({
30
- msg,
31
- chat,
32
- client,
33
- pluginRegistry
34
- });
29
+ const baseCtx = buildApi({ msg, chat, client, pluginRegistry });
35
30
 
36
31
  for (const plugin of pluginRegistry.values()) {
32
+ const ctx = { ...baseCtx, storage: buildStorageApi(plugin.name) };
37
33
  await runPlugin(plugin, ctx);
38
34
  }
39
35
  }
@@ -5,20 +5,44 @@
5
5
  * Plugins can only do what's here — never touch client directly.
6
6
  *
7
7
  * `chat` is already filtered by kernel (only allowed chats from .conf),
8
- * so plugins don't need and can't choose destination.
8
+ * so plugins don't need and can't choose destination, unless they use sendTo.
9
9
  */
10
10
 
11
11
  import { logger } from "#logger";
12
12
  import { t, createPluginT, reloadTranslations,
13
13
  getCurrentLang } from "#i18n";
14
- import { CONFIG } from "#config";
14
+ import { CONFIG, CONFIG_DIR } from "#config";
15
15
  import { enqueue } from "#download";
16
16
  import { emptyFolder } from "#utils/file";
17
17
  import { getChatId } from "#utils/getChatId";
18
18
  import pkg from "whatsapp-web.js";
19
+ import { mkdirSync } from "fs";
19
20
 
20
21
  const { MessageMedia } = pkg;
21
22
 
23
+ // ── Storage API ──────────────────────────────────────────────────────────────
24
+
25
+ export function buildStorageApi(pluginName) {
26
+ const dir = path.join(CONFIG_DIR, "data", pluginName);
27
+ mkdirSync(dir, { recursive: true });
28
+
29
+ return {
30
+ dir,
31
+
32
+ /**
33
+ * Resolves a path inside data directory.
34
+ * Make subdirectories automatically.
35
+ * @param {string} relativePath
36
+ * @returns {string}
37
+ */
38
+ path(relativePath) {
39
+ const resolved = path.join(dir, relativePath);
40
+ mkdirSync(path.dirname(resolved), { recursive: true });
41
+ return resolved;
42
+ },
43
+ };
44
+ }
45
+
22
46
  // ── Config API ───────────────────────────────────────────────────────────────
23
47
 
24
48
  function buildConfigApi() {
@@ -136,7 +160,32 @@ const log = {
136
160
  success: (...a) => logger.success(...a),
137
161
  };
138
162
 
139
- // -- Contact API --------------------------------------------------------------
163
+ // ── Contact API ──────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Normalizes a raw whatsapp-web.js Contact into a plain object.
167
+ * Used internally so both ctx.contacts and ctx.msg.getContact()
168
+ * always return the same shape.
169
+ * @param {import("whatsapp-web.js").Contact} c
170
+ * @returns {object}
171
+ */
172
+ function normalizeContact(c) {
173
+ return {
174
+ id: c.id._serialized,
175
+ number: c.number,
176
+ pushname: c.pushname ?? null,
177
+ name: c.name ?? null,
178
+ shortName: c.shortName ?? null,
179
+ isBusiness: c.isBusiness,
180
+ isEnterprise: c.isEnterprise,
181
+ isBlocked: c.isBlocked,
182
+ isMe: c.isMe,
183
+ isMyContact: c.isMyContact,
184
+ isWAContact: c.isWAContact,
185
+ isUser: c.isUser,
186
+ isGroup: c.isGroup,
187
+ };
188
+ }
140
189
 
141
190
  function buildContactsApi(client) {
142
191
  return {
@@ -148,21 +197,7 @@ function buildContactsApi(client) {
148
197
  async get(contactId) {
149
198
  try {
150
199
  const c = await client.getContactById(contactId);
151
- return {
152
- id: c.id._serialized,
153
- number: c.number,
154
- pushname: c.pushname ?? null,
155
- name: c.name ?? null,
156
- shortName: c.shortName ?? null,
157
- isBusiness: c.isBusiness,
158
- isEnterprise: c.isEnterprise,
159
- isBlocked: c.isBlocked,
160
- isMe: c.isMe,
161
- isMyContact: c.isMyContact,
162
- isWAContact: c.isWAContact,
163
- isUser: c.isUser,
164
- isGroup: c.isGroup,
165
- };
200
+ return normalizeContact(c);
166
201
  } catch {
167
202
  return null;
168
203
  }
@@ -170,7 +205,7 @@ function buildContactsApi(client) {
170
205
 
171
206
  /**
172
207
  * Get the profile picture URL of a contact.
173
- * Uses Contact#getProfilePicUrl()respects privacy settings.
208
+ * Respects privacy settings may return null.
174
209
  * @param {string} contactId
175
210
  * @returns {Promise<string|null>}
176
211
  */
@@ -202,7 +237,11 @@ function buildContactsApi(client) {
202
237
 
203
238
  // ── Internal media helpers ───────────────────────────────────────────────────
204
239
 
205
- function mediaFromSource(source, mimetype = "image/webp") {
240
+ /**
241
+ * @param {string|Buffer} source
242
+ * @param {string} mimetype — required, no ambiguous default
243
+ */
244
+ function mediaFromSource(source, mimetype) {
206
245
  return typeof source === "string"
207
246
  ? MessageMedia.fromFilePath(source)
208
247
  : new MessageMedia(mimetype, source.toString("base64"));
@@ -210,7 +249,6 @@ function mediaFromSource(source, mimetype = "image/webp") {
210
249
 
211
250
  /**
212
251
  * Returns send methods bound to a target that exposes `.sendMessage()`.
213
- * Used for both current-chat and sendTo variants.
214
252
  * @param {{ sendMessage: Function }} target
215
253
  */
216
254
  function makeSender(target) {
@@ -226,12 +264,12 @@ function makeSender(target) {
226
264
  const media = MessageMedia.fromFilePath(filePath);
227
265
  return target.sendMessage(media, { caption });
228
266
  },
229
- async audio(filePath) {
267
+ async audio(filePath, { asVoice = true } = {}) {
230
268
  const media = MessageMedia.fromFilePath(filePath);
231
- return target.sendMessage(media, { sendAudioAsVoice: true });
269
+ return target.sendMessage(media, { sendAudioAsVoice: asVoice });
232
270
  },
233
271
  async sticker(source) {
234
- const media = mediaFromSource(source);
272
+ const media = mediaFromSource(source, "image/webp");
235
273
  return target.sendMessage(media, { sendMediaAsSticker: true });
236
274
  },
237
275
  };
@@ -244,34 +282,93 @@ function chatIdTarget(client, chatId) {
244
282
  };
245
283
  }
246
284
 
247
- // ── Send APIs ────────────────────────────────────────────────────────────────
285
+ // ── Send API ─────────────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Runtime send API — current chat + .to() for other chats.
289
+ *
290
+ * ctx.send.text("oi")
291
+ * ctx.send.image("./foto.jpg", "legenda")
292
+ * ctx.send.to("5511@c.us").text("oi")
293
+ */
294
+ function buildSendApi(chat, client) {
295
+ const current = makeSender(chat);
248
296
 
249
- /** Send to a specific chat by ID. Available in both setup and runtime. */
250
- function buildSendToApi(client) {
251
297
  return {
252
- sendTo: (chatId, text, opts) => client.sendMessage(chatId, text, opts),
253
- sendImageTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).image(filePath, caption),
254
- sendVideoTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).video(filePath, caption),
255
- sendAudioTo: (chatId, filePath) => makeSender(chatIdTarget(client, chatId)).audio(filePath),
256
- sendStickerTo: (chatId, source) => makeSender(chatIdTarget(client, chatId)).sticker(source),
298
+ send: {
299
+ text: (text, opts) => current.text(text, opts),
300
+ image: (filePath, caption) => current.image(filePath, caption),
301
+ video: (filePath, caption) => current.video(filePath, caption),
302
+ audio: (filePath, opts) => current.audio(filePath, opts),
303
+ sticker: (source) => current.sticker(source),
304
+
305
+ /**
306
+ * Returns a sender bound to another chat.
307
+ * @param {string} chatId
308
+ * @returns {{ text, image, video, audio, sticker }}
309
+ */
310
+ to: (chatId) => makeSender(chatIdTarget(client, chatId)),
311
+ },
257
312
  };
258
313
  }
259
314
 
260
- /** Send to the current chat. Only available in runtime. */
261
- function buildSendApi(chat) {
262
- const sender = makeSender(chat);
315
+ /**
316
+ * Setup send API — no current chat, only .to().
317
+ *
318
+ * ctx.send.to(adminChatId).text("bot iniciado")
319
+ */
320
+ function buildSetupSendApi(client) {
263
321
  return {
264
- send: (text, opts) => sender.text(text, opts),
265
- sendImage: (filePath, caption) => sender.image(filePath, caption),
266
- sendVideo: (filePath, caption) => sender.video(filePath, caption),
267
- sendAudio: (filePath) => sender.audio(filePath),
268
- sendSticker: (source) => sender.sticker(source),
322
+ send: {
323
+ to: (chatId) => makeSender(chatIdTarget(client, chatId)),
324
+ },
325
+ };
326
+ }
327
+
328
+ // ── Events API (setup only) ───────────────────────────────────────────────────
329
+
330
+ function buildEventsApi(client) {
331
+ return {
332
+ /**
333
+ * Registers a persistent listener for a client event.
334
+ * Returns an 'off()' function to cancel the listener when the plugin wants.
335
+ *
336
+ * @param {string} event
337
+ * @param {Function} handler
338
+ * @returns {Function} off
339
+ *
340
+ * @example
341
+ * const off = ctx.events.on("group_join", (notification) => { ... });
342
+ * // cancels when the plugin doesn't want anymore:
343
+ * off();
344
+ */
345
+ on(event, handler) {
346
+ client.on(event, handler);
347
+ return () => client.off(event, handler);
348
+ },
349
+
350
+ /**
351
+ * Returns a Promise that resolves on the next ocurrence of the event.
352
+ * Useful for waiting a specific event without having to manage listeners manually.
353
+ *
354
+ * @param {string} event
355
+ * @returns {Promise<any>}
356
+ *
357
+ * @example
358
+ * const notification = await ctx.events.once("group_join");
359
+ */
360
+ once(event) {
361
+ return new Promise((resolve) => client.once(event, resolve));
362
+ }
269
363
  };
270
364
  }
271
365
 
272
366
  // ── Base API (shared between setup and runtime) ───────────────────────────────
273
367
 
274
- function buildBaseApi(client, pluginRegistry) {
368
+ function buildBaseApi(client, pluginRegistry, pluginName) {
369
+ const botId = client.info?.wid?._serialized ?? null;
370
+ if (!botId) logger.warn("[pluginApi] botId is null - client may not be ready yet.");
371
+
275
372
  return {
276
373
  log,
277
374
  t,
@@ -280,9 +377,9 @@ function buildBaseApi(client, pluginRegistry) {
280
377
  utils: buildUtilsApi(),
281
378
  download: buildDownloadApi(),
282
379
  plugins: buildPluginsApi(pluginRegistry),
283
- botId: client.info?.wid?._serialized ?? null,
284
- contacts: buildContactsApi(),
285
- ...buildSendToApi(client),
380
+ contacts: buildContactsApi(client),
381
+ storage: buildStorageApi(pluginName),
382
+ botId,
286
383
  };
287
384
  }
288
385
 
@@ -296,9 +393,11 @@ function buildBaseApi(client, pluginRegistry) {
296
393
  * @param {Map<string, any>} pluginRegistry
297
394
  * @returns {object}
298
395
  */
299
- export function buildSetupApi(client, pluginRegistry) {
396
+ export function buildSetupApi(client, pluginRegistry, pluginName) {
300
397
  return {
301
- ...buildBaseApi(client, pluginRegistry),
398
+ ...buildBaseApi(client, pluginRegistry, pluginName),
399
+ ...buildSetupSendApi(client),
400
+ events: buildEventsApi(client),
302
401
  };
303
402
  }
304
403
 
@@ -316,9 +415,15 @@ export function buildSetupApi(client, pluginRegistry) {
316
415
  * @returns {object} ctx
317
416
  */
318
417
  export function buildApi({ msg, chat, client, pluginRegistry }) {
418
+ const prefix = CONFIG.CMD_PREFIX ?? "!";
419
+ const rawArgs = msg.body?.trim().split(/\s+/) ?? [];
420
+ const command = rawArgs[0]?.toLowerCase().startsWith(prefix)
421
+ ? rawArgs[0].slice(prefix.length).toLowerCase()
422
+ : rawArgs[0]?.toLowerCase() ?? "";
423
+
319
424
  return {
320
425
  ...buildBaseApi(client, pluginRegistry),
321
- ...buildSendApi(chat),
426
+ ...buildSendApi(chat, client),
322
427
 
323
428
  // ── msg ──────────────────────────────────────────────────
324
429
 
@@ -328,11 +433,18 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
328
433
  fromMe: msg.fromMe,
329
434
  sender: msg.author || msg.from,
330
435
  senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
331
- args: msg.body?.trim().split(/\s+/) ?? [],
332
436
 
333
- /** Check if message starts with a command. */
437
+ /** Command token without prefix (e.g. "play" for "!play foo"). */
438
+ command,
439
+
440
+ /** Arguments after the command token. */
441
+ args: rawArgs.slice(1),
442
+
443
+ /**
444
+ * Check if message is a given command.
445
+ * @param {string} cmd — without prefix
446
+ */
334
447
  is(cmd) {
335
- const command = msg.body?.trim().split(/\s+/)[0].toLowerCase();
336
448
  return command === cmd.toLowerCase();
337
449
  },
338
450
 
@@ -360,8 +472,18 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
360
472
  return msg.react(emoji);
361
473
  },
362
474
 
475
+ /**
476
+ * Get the sender as a normalized Contact object.
477
+ * Same shape as ctx.contacts.get().
478
+ * @returns {Promise<object|null>}
479
+ */
363
480
  async getContact() {
364
- return msg.getContact();
481
+ try {
482
+ const c = await msg.getContact();
483
+ return normalizeContact(c);
484
+ } catch {
485
+ return null;
486
+ }
365
487
  },
366
488
  },
367
489
 
@@ -371,6 +493,32 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
371
493
  id: chat.id._serialized,
372
494
  name: chat.name || chat.id.user,
373
495
  isGroup: /@g\.us$/.test(chat.id._serialized),
496
+
497
+ /**
498
+ * List of group participants.
499
+ * Returns [] for non-group chats.
500
+ * @returns {Promise<Array<{ id: string, isAdmin: boolean, isSuperAdmin: boolean }>>}
501
+ */
502
+ async getParticipants() {
503
+ if (!chat.participants) return [];
504
+ return chat.participants.map((p) => ({
505
+ id: p.id._serialized,
506
+ isAdmin: p.isAdmin,
507
+ isSuperAdmin: p.isSuperAdmin,
508
+ }));
509
+ },
510
+
511
+ /**
512
+ * Check if a contact is an admin of this group.
513
+ * Always returns false for non-group chats.
514
+ * @param {string} contactId
515
+ * @returns {Promise<boolean>}
516
+ */
517
+ async isAdmin(contactId) {
518
+ return chat.participants?.some(
519
+ (p) => p.id._serialized === contactId && (p.isAdmin || p.isSuperAdmin)
520
+ ) ?? false;
521
+ },
374
522
  },
375
523
  };
376
524
  }
@@ -15,6 +15,7 @@ import { logger } from "#logger";
15
15
  import { t } from "#i18n";
16
16
  import { pathToFileURL } from "url";
17
17
  import { PATHS } from "#config";
18
+ import { buildStorageApi } from "#manyapi";
18
19
 
19
20
  const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
20
21
 
@@ -63,10 +64,11 @@ export async function loadPlugins(activePlugins) {
63
64
  *
64
65
  * @param {object} api — api without message context (only sendTo, log, schedule...)
65
66
  */
66
- export async function setupPlugins(api) {
67
+ export async function setupPlugins(baseApi) {
67
68
  for (const plugin of pluginRegistry.values()) {
68
69
  if (plugin.status !== "active" || !plugin.setup) continue;
69
70
  try {
71
+ const api = { ...baseApi, storage: buildStorageApi(plugin.name) };
70
72
  await plugin.setup(api);
71
73
  } catch (err) {
72
74
  logger.error(t("system.pluginSetupFailed", { name: plugin.name, message: err.message }));
@@ -61,6 +61,7 @@
61
61
  "messageProcess": "Failed to process message",
62
62
  "stack": "Stack",
63
63
  "chromeNotFound": "Chrome not found. Downloading...",
64
- "couldNotDownloadChrome": "Could not download Chrome: "
64
+ "couldNotDownloadChrome": "Could not download Chrome: ",
65
+ "OSNotSupported": "Unsupported operating system: {{os}}. If you have any questions, please visit: https://manybot.stxerr.dev/docs/getting-started/"
65
66
  }
66
67
  }
@@ -56,6 +56,7 @@
56
56
  "messageProcess": "Error al procesar el mensaje",
57
57
  "stack": "Stack",
58
58
  "chromeNotFound": "No se ha encontrado Chrome. Descargando...",
59
- "couldNotDownloadChrome": "No se ha podido descargar Chrome: "
59
+ "couldNotDownloadChrome": "No se ha podido descargar Chrome: ",
60
+ "OSNotSupported": "Sistema operativo no compatible: {{os}}. Si tienes alguna duda, consulta: https://manybot.stxerr.dev/docs/getting-started/"
60
61
  }
61
62
  }
@@ -61,6 +61,8 @@
61
61
  "messageProcess": "Falha ao processar mensagem",
62
62
  "stack": "Stack",
63
63
  "chromeNotFound": "Chrome não encontrado. Baixando...",
64
- "couldNotDownloadChrome": "Não foi possível baixar o Chrome: "
64
+ "couldNotDownloadChrome": "Não foi possível baixar o Chrome: ",
65
+ "OSNotSupported": "Sistema operacional não suportado: {{os}}. Em caso de dúvidas, consulte: https://manybot.stxerr.dev/docs/getting-started/"
66
+
65
67
  }
66
68
  }