@manybot/manybot 5.8.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 (45) hide show
  1. package/README.md +15 -7
  2. package/dist/client/store.js +35 -1
  3. package/dist/download/queue.js +13 -4
  4. package/dist/drivers/baileys/adapter.js +75 -8
  5. package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
  6. package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
  7. package/dist/drivers/baileys/api/index.js +212 -38
  8. package/dist/drivers/baileys/index.js +30 -6
  9. package/dist/drivers/baileys/messageHandler.js +207 -21
  10. package/dist/drivers/baileys/messageHandler.test.js +256 -14
  11. package/dist/drivers/baileysAdapter.test.js +97 -0
  12. package/dist/drivers/jid.js +26 -0
  13. package/dist/drivers/jid.test.js +35 -1
  14. package/dist/i18n/index.js +5 -22
  15. package/dist/kernel/chatOverrides.js +46 -0
  16. package/dist/kernel/chatOverrides.test.js +59 -0
  17. package/dist/kernel/commandAccess.test.js +2 -2
  18. package/dist/kernel/commandDeprecation.js +4 -2
  19. package/dist/kernel/commandDeprecation.test.js +8 -1
  20. package/dist/kernel/commandMenu.js +91 -2
  21. package/dist/kernel/commandMenu.test.js +131 -2
  22. package/dist/kernel/commandPermissions.js +69 -23
  23. package/dist/kernel/commandPermissions.test.js +77 -9
  24. package/dist/kernel/commandRegistry.js +167 -43
  25. package/dist/kernel/commandRegistry.test.js +4 -2
  26. package/dist/kernel/commandsConfig.js +470 -38
  27. package/dist/kernel/commandsConfig.test.js +249 -3
  28. package/dist/kernel/contactAutoSave.js +6 -6
  29. package/dist/kernel/coreCommands.js +62 -0
  30. package/dist/kernel/pluginApi.test.js +20 -3
  31. package/dist/kernel/pluginGuard.js +5 -3
  32. package/dist/kernel/pluginLoader.js +73 -10
  33. package/dist/kernel/pluginLoader.test.js +111 -1
  34. package/dist/kernel/runCommand.js +57 -18
  35. package/dist/kernel/runCommand.test.js +269 -7
  36. package/dist/kernel/settingsDb.js +15 -2
  37. package/dist/kernel/testConfig.js +9 -0
  38. package/dist/locales/en.json +14 -1
  39. package/dist/locales/es.json +17 -4
  40. package/dist/locales/pt.json +18 -5
  41. package/dist/plugins/__manybot_integration__/index.js +33 -16
  42. package/dist/plugins/__manybot_integration__/index.test.js +42 -8
  43. package/dist/utils/phoneNumber.js +83 -0
  44. package/dist/utils/phoneNumber.test.js +53 -0
  45. package/package.json +4 -3
@@ -10,8 +10,16 @@
10
10
  * 3. Pass context to all active plugins
11
11
  *
12
12
  * Each plugin decides whether to act or ignore.
13
+ *
14
+ * v6 loading indicators: a matched command may declare a `loading:` spec
15
+ * (or reference a top-level `loading_presets:` preset) which controls
16
+ * the user-visible "processando..." signal — `reaction`, `typing`,
17
+ * `recording_audio`, `spinner`, or `none`. The spec is resolved at
18
+ * registry build time (defaults → category → command → sub), so by the
19
+ * time the dispatch path reads it we only have to apply it.
13
20
  */
14
- import { CHATS, EXCLUDE_CHATS, CMD_PREFIX } from "#config";
21
+ import { CHATS, EXCLUDE_CHATS } from "#config";
22
+ import { getChatPrefix, getChatLocale } from "#kernel/chatOverrides.js";
15
23
  import { buildApi, buildChatFromMsg, buildMessageContext } from "./api/index.js";
16
24
  import { pluginRegistry } from "#kernel/pluginLoader.js";
17
25
  import { getCommandByInvocation, getCommandRegistry } from "#kernel/commandRegistry.js";
@@ -36,6 +44,122 @@ function extractCommand(body, prefix) {
36
44
  const first = body.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
37
45
  return first.startsWith(prefix) ? first.slice(prefix.length) : "";
38
46
  }
47
+ /**
48
+ * Drive the per-command loading indicator described by the registry.
49
+ *
50
+ * - `reaction` : drop an emoji reaction on the source message,
51
+ * update with onSuccess/onError on completion.
52
+ * - `typing` : native WhatsApp "typing..." presence, refreshed
53
+ * on a 4s interval; cleared on completion.
54
+ * - `recording_audio` : native WhatsApp "recording audio..." presence,
55
+ * same interval/clear semantics as typing.
56
+ * - `spinner` : self-sent message showing a frame sequence,
57
+ * edited every `intervalMs` (default 1500); last
58
+ * frame stays on completion.
59
+ * - `none` : no-op — caller doesn't see a difference.
60
+ *
61
+ * All best-effort: any driver-level failure is logged at `warn` level
62
+ * and swallowed so a broken indicator never breaks a command.
63
+ */
64
+ function startLoadingIndicator(spec, contract, rawJid, msgKey) {
65
+ const noop = async () => { };
66
+ if (!spec)
67
+ return { stop: noop };
68
+ if (spec.type === "reaction") {
69
+ const icon = spec.icon ?? "⏳";
70
+ if (msgKey) {
71
+ contract.react(rawJid, msgKey, icon).catch((e) => {
72
+ const err = e instanceof Error ? e : new Error(String(e));
73
+ logger.warn(`[messageHandler] loading.reaction send failed: ${err.message}`);
74
+ });
75
+ }
76
+ return {
77
+ stop: async (outcome) => {
78
+ if (!msgKey)
79
+ return;
80
+ const next = outcome === "success" ? spec.onSuccess : spec.onError;
81
+ try {
82
+ await contract.react(rawJid, msgKey, next ?? "");
83
+ }
84
+ catch (e) {
85
+ const err = e instanceof Error ? e : new Error(String(e));
86
+ logger.warn(`[messageHandler] loading.reaction clear failed: ${err.message}`);
87
+ }
88
+ },
89
+ };
90
+ }
91
+ if (spec.type === "typing" || spec.type === "recording_audio") {
92
+ const presence = spec.type === "typing" ? "composing" : "recording";
93
+ const interval = setInterval(() => {
94
+ contract.sendPresenceUpdate(presence, rawJid).catch(() => { });
95
+ }, 4000);
96
+ contract.sendPresenceUpdate(presence, rawJid).catch(() => { });
97
+ return {
98
+ stop: async () => {
99
+ clearInterval(interval);
100
+ contract.sendPresenceUpdate("paused", rawJid).catch(() => { });
101
+ },
102
+ };
103
+ }
104
+ if (spec.type === "spinner") {
105
+ const frames = spec.frames && spec.frames.length > 0 ? spec.frames : ["⏳"];
106
+ const intervalMs = Math.max(1000, spec.intervalMs ?? 1500);
107
+ let frameIdx = 0;
108
+ let sentMsgId = null;
109
+ const cycle = async () => {
110
+ try {
111
+ if (sentMsgId === null) {
112
+ const sent = await contract.sendText(rawJid, frames[0]);
113
+ sentMsgId = sent.id;
114
+ }
115
+ else {
116
+ frameIdx = (frameIdx + 1) % frames.length;
117
+ await contract.editMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, frames[frameIdx]);
118
+ }
119
+ }
120
+ catch {
121
+ // Spinner is best-effort — leave the previous frame in place
122
+ // when an edit fails rather than aborting the whole indicator.
123
+ }
124
+ };
125
+ cycle().catch(() => { });
126
+ const interval = setInterval(() => { cycle().catch(() => { }); }, intervalMs);
127
+ return {
128
+ stop: async (outcome) => {
129
+ clearInterval(interval);
130
+ if (sentMsgId !== null) {
131
+ const finalText = outcome === "success" ? spec.onSuccess : spec.onError;
132
+ try {
133
+ if (finalText !== undefined) {
134
+ await contract.editMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, finalText);
135
+ }
136
+ else {
137
+ await contract.deleteMessage(rawJid, { id: sentMsgId, remoteJid: rawJid, fromMe: true }, true);
138
+ }
139
+ }
140
+ catch (e) {
141
+ const err = e instanceof Error ? e : new Error(String(e));
142
+ logger.warn(`[messageHandler] loading.spinner finalize failed: ${err.message}`);
143
+ }
144
+ }
145
+ },
146
+ };
147
+ }
148
+ // `none` or any future variant — nothing to do.
149
+ return { stop: noop };
150
+ }
151
+ /**
152
+ * Resolve the loading spec for the matched command/sub. Subcommands win
153
+ * (their own chain inheritance ran at registry build time); falls back
154
+ * to the parent's resolved spec.
155
+ */
156
+ function resolveLoadingForDispatch(entry, resolution) {
157
+ if (resolution.target.kind === "sub")
158
+ return resolution.target.sub.loading ?? entry.loading;
159
+ if (resolution.target.kind === "parent")
160
+ return entry.loading;
161
+ return null;
162
+ }
39
163
  // ── Dedup of already-processed messages ────────────────────────────────────
40
164
  // WhatsApp resends messages without a delivery/read confirmation (the
41
165
  // protocol's own retry, usually up to 3 times) when the socket reconnects.
@@ -107,18 +231,39 @@ export async function handleMessage(msg, contract, store) {
107
231
  // Caps how many chats get answered at the same time — see SECURITY_LEVEL.
108
232
  const releaseChatSlot = await acquireChatSlot(jid);
109
233
  try {
110
- await runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid);
234
+ await runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid, rawKey);
111
235
  }
112
236
  finally {
113
237
  releaseChatSlot();
114
238
  }
115
239
  }
116
- async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid) {
117
- const command = extractCommand(msgCtx.body, CMD_PREFIX);
240
+ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid, rawKey) {
241
+ const chatPrefix = getChatPrefix(msg.chatId);
242
+ const chatLocale = getChatLocale(msg.chatId);
243
+ const command = extractCommand(msgCtx.body, chatPrefix);
118
244
  const registry = getCommandRegistry();
119
245
  // 0. Welcome message (first message within the configured window)
120
- if (registry) {
121
- const welcomeMsg = checkAndTriggerWelcomeMessage(msgCtx.sender, registry);
246
+ //
247
+ // Two gates before we even consider firing the welcome:
248
+ //
249
+ // - `!msg.fromMe`: skip when the bot itself "sent" the message
250
+ // that triggered this dispatch. Baileys' history-sync replays
251
+ // the bot's own outgoing messages as `messages.upsert` with
252
+ // `fromMe=true` on every reconnect — without this gate, the
253
+ // bot would greet itself in its own DM/group on every restart
254
+ // the moment history-sync completes. The welcome is for
255
+ // *incoming* messages only.
256
+ //
257
+ // - `!chat.isGroup`: skip when the message arrived in a group.
258
+ // A new member joining a group shouldn't get a per-member
259
+ // welcome reply inside the group's conversation — it's noise
260
+ // and reads weird in front of everyone else. The welcome is
261
+ // only meaningful in a 1:1 (DM) chat where the message
262
+ // originates from the user being greeted, and where the reply
263
+ // goes back to the same chat (`msgCtx.reply` already targets
264
+ // the source chat).
265
+ if (registry && !msg.fromMe && !chat.isGroup) {
266
+ const welcomeMsg = checkAndTriggerWelcomeMessage(msgCtx.sender ?? msg.chatId, registry, { body: msgCtx.body, timestamp: msg.timestamp }, chatLocale, chatPrefix);
122
267
  if (welcomeMsg) {
123
268
  try {
124
269
  await msgCtx.reply.text(welcomeMsg);
@@ -131,9 +276,9 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
131
276
  }
132
277
  // 1. Menu aliases match (overview / category / manual / notFound)
133
278
  if (command && registry && registry.menuAliases.has(command)) {
134
- const rawArgs = msgCtx.body.trim().slice(CMD_PREFIX.length + command.length).trim();
279
+ const rawArgs = msgCtx.body.trim().slice(chatPrefix.length + command.length).trim();
135
280
  const scope = chat.isGroup ? "group" : "dm";
136
- const menuResponse = handleMenuCommand(command, rawArgs, registry, undefined, scope);
281
+ const menuResponse = handleMenuCommand(command, rawArgs, registry, chatLocale, scope);
137
282
  try {
138
283
  await msgCtx.reply.text(menuResponse);
139
284
  }
@@ -150,7 +295,7 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
150
295
  // active in production. Kept separate from the `matched` top-level
151
296
  // permission pre-check further down (unchanged, still gates the whole
152
297
  // per-message plugin loop exactly as before `runCommand` existed).
153
- const rawArgsForDispatch = command ? msgCtx.body.trim().slice(CMD_PREFIX.length + command.length).trim() : "";
298
+ const rawArgsForDispatch = command ? msgCtx.body.trim().slice(chatPrefix.length + command.length).trim() : "";
154
299
  const resolution = command ? resolveDispatch(command, rawArgsForDispatch) : { target: { kind: "none" } };
155
300
  if (matched) {
156
301
  const permApi = buildApi({
@@ -165,7 +310,7 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
165
310
  const permResult = await checkPermission(matched, {
166
311
  isGroup: permApi.chat.isGroup,
167
312
  chatId: permApi.chat.id,
168
- senderId: msgCtx.sender,
313
+ sender: { lid: msgCtx.sender, pn: msgCtx.senderPn },
169
314
  isSenderAdmin: () => permApi.chat.isSenderAdmin(),
170
315
  isBotAdmin: () => permApi.chat.isBotAdmin(),
171
316
  });
@@ -186,7 +331,7 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
186
331
  // invoke any plugin (legacy or migrated) for this message.
187
332
  if (matched && matched.source === "text" && matched.text !== null) {
188
333
  try {
189
- const textContent = resolveLocalizedString(matched.text) ?? "";
334
+ const textContent = resolveLocalizedString(matched.text, chatLocale) ?? "";
190
335
  await msgCtx.reply.text(textContent);
191
336
  }
192
337
  catch (e) {
@@ -216,6 +361,37 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
216
361
  }
217
362
  }
218
363
  const matchedPlugin = matched && matched.source === "plugin" ? matched : null;
364
+ // "core" (ping/status/config/...) is a synthetic pseudo-plugin: it only
365
+ // exists in a *local* `allPlugins` map built inside commandRegistry.ts
366
+ // for registry-building purposes — it is never inserted into the real
367
+ // `pluginRegistry` singleton that the loop below iterates. Left as-is,
368
+ // no "core"-sourced command could ever be dispatched (runCommand() would
369
+ // never be called for it, plugin count or not), so it's handled here
370
+ // explicitly, once, before the real-plugin loop.
371
+ if (matchedPlugin && matchedPlugin.pluginName === "core") {
372
+ const coreCtx = buildApi({
373
+ msg,
374
+ chat,
375
+ contract,
376
+ store,
377
+ pluginRegistry,
378
+ pluginName: "core",
379
+ guardOptions: {},
380
+ });
381
+ const coreLoading = startLoadingIndicator(resolveLoadingForDispatch(matchedPlugin, resolution), contract, rawJid, rawKey);
382
+ let coreOutcome = "success";
383
+ try {
384
+ await runCommand({ pluginName: "core", ctx: coreCtx, resolution, reply: msgCtx.reply, chatId: msg.chatId });
385
+ }
386
+ catch (e) {
387
+ coreOutcome = "error";
388
+ const err = e instanceof Error ? e : new Error(String(e));
389
+ logger.warn(`[messageHandler] runCommand crashed for core: ${err.message}`);
390
+ }
391
+ finally {
392
+ await coreLoading.stop(coreOutcome);
393
+ }
394
+ }
219
395
  for (const plugin of pluginRegistry.values()) {
220
396
  const ctx = buildApi({
221
397
  msg,
@@ -226,7 +402,7 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
226
402
  pluginName: plugin.name,
227
403
  guardOptions: plugin.guardOptions,
228
404
  });
229
- const useTyping = plugin.guardOptions?.typing !== false;
405
+ const useTyping = !matchedPlugin && plugin.guardOptions?.typing !== false;
230
406
  let typingInterval;
231
407
  if (useTyping) {
232
408
  // Refresh presence every 4s so WhatsApp doesn't auto-clear it
@@ -244,16 +420,26 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
244
420
  // this same plugin that did NOT match the registry) keep their legacy
245
421
  // run(ctx).
246
422
  if (matchedPlugin && matchedPlugin.pluginName === plugin.name && matchedPlugin.handler) {
247
- // runCommand() opts into rethrow so its Phase-8 fireAlert("plugin_crash")
248
- // catch actually runs (runPlugin() swallows by default — see pluginGuard.ts).
249
- // Swallow here too, at the boundary: this loop must never crash the bot,
250
- // same guarantee the legacy runPlugin(plugin, ctx) branch below already has.
423
+ // Loading indicator: the resolved spec is read once per dispatch
424
+ // and drives start/stop around the runCommand call.
425
+ const loading = startLoadingIndicator(resolveLoadingForDispatch(matchedPlugin, resolution), contract, rawJid, rawKey);
426
+ let outcome = "success";
251
427
  try {
252
- await runCommand({ pluginName: plugin.name, ctx, resolution, reply: msgCtx.reply });
428
+ // runCommand() opts into rethrow so its Phase-8 fireAlert("plugin_crash")
429
+ // catch actually runs (runPlugin() swallows by default — see pluginGuard.ts).
430
+ // Swallow here too, at the boundary: this loop must never crash the bot,
431
+ // same guarantee the legacy runPlugin(plugin, ctx) branch below already has.
432
+ try {
433
+ await runCommand({ pluginName: plugin.name, ctx, resolution, reply: msgCtx.reply, chatId: msg.chatId });
434
+ }
435
+ catch (e) {
436
+ outcome = "error";
437
+ const err = e instanceof Error ? e : new Error(String(e));
438
+ logger.warn(`[messageHandler] runCommand crashed for plugin "${plugin.name}": ${err.message}`);
439
+ }
253
440
  }
254
- catch (e) {
255
- const err = e instanceof Error ? e : new Error(String(e));
256
- logger.warn(`[messageHandler] runCommand crashed for plugin "${plugin.name}": ${err.message}`);
441
+ finally {
442
+ await loading.stop(outcome);
257
443
  }
258
444
  }
259
445
  else {
@@ -273,7 +459,7 @@ async function runPluginsForMessage(msg, chat, msgCtx, contract, store, rawJid)
273
459
  // alongside this fallback.
274
460
  if (!matched && command && registry?.menu.notFoundFallback) {
275
461
  try {
276
- await msgCtx.reply.text(renderNotFound(command, registry));
462
+ await msgCtx.reply.text(renderNotFound(command, registry, chatLocale));
277
463
  }
278
464
  catch (e) {
279
465
  const err = e instanceof Error ? e : new Error(String(e));