@manybot/manybot 5.3.2 → 5.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![ManyBot Logo](logo.png)
4
4
 
5
- ![Node.js 24+](https://img.shields.io/badge/Node.js-20+-339933?logo=node.js&logoColor=white)
5
+ ![Node.js 24+](https://img.shields.io/badge/Node.js-24+-339933?logo=node.js&logoColor=white)
6
6
  ![npm 9+](https://img.shields.io/badge/npm-9+-CB3837?logo=npm&logoColor=white)
7
7
  ![GPL v3](https://img.shields.io/badge/License-GPL--v3-blue.svg)
8
8
  ![Linux](https://img.shields.io/badge/Linux%20%7C%20Windows-lightgrey)
@@ -46,9 +46,9 @@ Browse available plugins at **[manybot.org/plugins](https://manybot.org/plugins/
46
46
  All kinds of contributions are welcome:
47
47
 
48
48
  - **Bug reports and feature requests**: open an issue on GitHub or Codeberg
49
- - **Code**: pull requests are welcome on [GitHub](https://github.com/many-bot/manybot) or [Codeberg](https://codeberg.org/many-bot/manybot); patches by email (`devel+manybot.org`) are also accepted - subscribe to the mailing list [here](https://list.stxerr.dev).
49
+ - **Code**: pull requests are welcome on [GitHub](https://github.com/many-bot/manybot) or [Codeberg](https://codeberg.org/many-bot/manybot); patches by email (`manybot@pm.me`) are also accepted.
50
50
  - **Plugins**: submit your plugin to [manyplug-repo](https://github.com/many-bot/manyplug-repo), which has instructions on how to do it
51
- - **Anything else**: suggestions, translations, documentation fixes - reach out by email or open an issue
51
+ - **Anything else**: suggestions, translations, documentation fixes, art - reach out by email or open an issue
52
52
 
53
53
  ## License
54
54
 
@@ -348,9 +348,8 @@ const log = {
348
348
  * @param {string} jid
349
349
  * @param {WAStoreContact} [info]
350
350
  */
351
- function mentionDisplayName(jid, info) {
352
- const number = jid.split("@")[0];
353
- return info?.name ?? info?.verifiedName ?? info?.notify ?? number;
351
+ function mentionDisplayName(jid) {
352
+ return jid.split("@")[0];
354
353
  }
355
354
  /**
356
355
  * Build a normalized contact object from a JID and optional store metadata.
@@ -410,7 +409,23 @@ async function normalizeContact(jid, info, botJid, sock) {
410
409
  isWAAccount,
411
410
  isUser: !jid.endsWith("@g.us"),
412
411
  isGroup: jid.endsWith("@g.us"),
413
- mention: { text: `@${mentionDisplayName(jid, info)}`, mentions: [jid] },
412
+ mention: { text: `@${mentionDisplayName(jid)}`, mentions: [toWireJid(jid)] },
413
+ };
414
+ }
415
+ // ── Chats API ─────────────────────────────────────────────────────────────────
416
+ function buildChatsApi(store) {
417
+ return {
418
+ /**
419
+ * Chats currently known from the in-memory cache (populated from
420
+ * Baileys' chats.upsert / messaging-history.set events) — no network call.
421
+ * @returns {Array<{ id: string, name: string, isGroup: boolean }>}
422
+ */
423
+ all() {
424
+ return store.chats.all().map((c) => {
425
+ const id = normalizeJid(c.id);
426
+ return { id, name: c.name, isGroup: id.endsWith("@g.us") };
427
+ });
428
+ },
414
429
  };
415
430
  }
416
431
  // ── Contact API ───────────────────────────────────────────────────────────────
@@ -420,6 +435,7 @@ function buildContactsApi(sock, store, botJid) {
420
435
  * Get a normalized contact object by JID.
421
436
  * @param {string} contactId
422
437
  * @param {{groupId?: string}} [opts] — when `contactId` is a raw `@lid`, this
438
+
423
439
  * always cross-checks it against Baileys' own protocol-level
424
440
  * `sock.signalRepository.lidMapping` first (populated from real Signal
425
441
  * session/identity resolution — not a heuristic, and doesn't need a
@@ -588,6 +604,15 @@ function buildContactsApi(sock, store, botJid) {
588
604
  },
589
605
  };
590
606
  }
607
+ function makeHistoryArray(entries, store) {
608
+ const arr = entries;
609
+ arr.last = (n) => makeHistoryArray(typeof n === "number" ? entries.slice(-n) : entries.slice(), store);
610
+ arr.from = (senderId) => {
611
+ const target = normalizeJid(store.resolveJid(normalizeJid(senderId)));
612
+ return makeHistoryArray(entries.filter((e) => e.sender === target), store);
613
+ };
614
+ return arr;
615
+ }
591
616
  export function buildMessageContext(msg, sock, store, guardOptions = {}) {
592
617
  const body = getMsgBody(msg);
593
618
  const prefix = CONFIG.CMD_PREFIX;
@@ -613,6 +638,8 @@ export function buildMessageContext(msg, sock, store, guardOptions = {}) {
613
638
  }
614
639
  : null;
615
640
  return {
641
+ id: msg.key.id ?? "",
642
+ timestamp: Number(msg.messageTimestamp) || 0,
616
643
  body,
617
644
  type: getMsgType(msg),
618
645
  fromMe: !!(msg.key.fromMe),
@@ -765,60 +792,94 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
765
792
  return new MessageHandle((async () => {
766
793
  const quotedMsg = await resolveQuoted();
767
794
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
795
+ // mentions/linkPreview are part of the message CONTENT, not the send
796
+ // options — Baileys reads contextInfo.mentionedJid off the content
797
+ // object. Putting them on sendOpts (3rd arg) is silently ignored: the
798
+ // message sends fine, "@name" shows as plain text, and nobody actually
799
+ // gets tagged/notified.
800
+ const messageContent = { text: content };
768
801
  if (opts.linkPreview === false) {
769
- sendOpts.linkPreview = false;
802
+ messageContent.linkPreview = null;
770
803
  }
771
804
  if (opts.mentions?.length) {
772
- sendOpts.mentions = opts.mentions;
805
+ messageContent.mentions = await resolveMentionJids(sock, store, jid, opts.mentions);
773
806
  }
774
807
  await waitForSendSlot(normJid, { cooldown, jitter });
775
808
  await simulateState(toPresenceCapable(sock), jid, typingDuration(content), "typing");
776
- return sock.sendMessage(jid, { text: content }, sendOpts);
809
+ return sock.sendMessage(jid, messageContent, sendOpts);
777
810
  })(), sock, store, { cooldown, jitter });
778
811
  },
779
812
  image(filePath, caption = "", opts = {}) {
780
813
  return new MessageHandle((async () => {
781
814
  const quotedMsg = await resolveQuoted();
782
815
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
816
+ await waitForSendSlot(normJid, { cooldown, jitter });
817
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
818
+ const buffer = await readFile(filePath);
819
+ // viewOnce/mentions are part of the message CONTENT — see note above.
820
+ const messageContent = { image: buffer, caption };
783
821
  if (opts.viewOnce) {
784
- sendOpts.viewOnce = true;
822
+ messageContent.viewOnce = true;
785
823
  }
786
824
  if (opts.mentions?.length) {
787
- sendOpts.mentions = opts.mentions;
825
+ messageContent.mentions = await resolveMentionJids(sock, store, jid, opts.mentions);
788
826
  }
789
- await waitForSendSlot(normJid, { cooldown, jitter });
790
- await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
791
- const buffer = await readFile(filePath);
792
- return sock.sendMessage(jid, { image: buffer, caption }, sendOpts);
827
+ return sock.sendMessage(jid, messageContent, sendOpts);
793
828
  })(), sock, store, { cooldown, jitter });
794
829
  },
795
830
  video(filePath, caption = "", opts = {}) {
796
831
  return new MessageHandle((async () => {
797
832
  const quotedMsg = await resolveQuoted();
798
833
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
834
+ await waitForSendSlot(normJid, { cooldown, jitter });
835
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
836
+ const buffer = await readFile(filePath);
837
+ // viewOnce/mentions are part of the message CONTENT — see note above.
838
+ const messageContent = { video: buffer, caption };
799
839
  if (opts.viewOnce) {
800
- sendOpts.viewOnce = true;
840
+ messageContent.viewOnce = true;
801
841
  }
802
842
  if (opts.mentions?.length) {
803
- sendOpts.mentions = opts.mentions;
843
+ messageContent.mentions = await resolveMentionJids(sock, store, jid, opts.mentions);
804
844
  }
845
+ return sock.sendMessage(jid, messageContent, sendOpts);
846
+ })(), sock, store, { cooldown, jitter });
847
+ },
848
+ /**
849
+ * Send a GIF. WhatsApp has no native GIF format — this sends an mp4
850
+ * with the `gifPlayback` flag, which the client auto-loops, muted.
851
+ * `filePath` must already be mp4-encoded (no .gif input, no conversion).
852
+ */
853
+ gif(filePath, caption = "", opts = {}) {
854
+ return new MessageHandle((async () => {
855
+ const quotedMsg = await resolveQuoted();
856
+ const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
805
857
  await waitForSendSlot(normJid, { cooldown, jitter });
806
858
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
807
859
  const buffer = await readFile(filePath);
808
- return sock.sendMessage(jid, { video: buffer, caption }, sendOpts);
860
+ const messageContent = { video: buffer, caption, gifPlayback: true };
861
+ if (opts.viewOnce) {
862
+ messageContent.viewOnce = true;
863
+ }
864
+ if (opts.mentions?.length) {
865
+ messageContent.mentions = await resolveMentionJids(sock, store, jid, opts.mentions);
866
+ }
867
+ return sock.sendMessage(jid, messageContent, sendOpts);
809
868
  })(), sock, store, { cooldown, jitter });
810
869
  },
811
870
  audio(filePath, { asVoice = true, viewOnce = false } = {}) {
812
871
  return new MessageHandle((async () => {
813
872
  const quotedMsg = await resolveQuoted();
814
873
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
815
- if (viewOnce) {
816
- sendOpts.viewOnce = true;
817
- }
818
874
  await waitForSendSlot(normJid, { cooldown, jitter });
819
875
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "recording");
820
876
  const buffer = await readFile(filePath);
821
- return sock.sendMessage(jid, { audio: buffer, mimetype: "audio/mp4", ptt: asVoice }, sendOpts);
877
+ // viewOnce is part of the message CONTENT see note above.
878
+ const messageContent = { audio: buffer, mimetype: "audio/mp4", ptt: asVoice };
879
+ if (viewOnce) {
880
+ messageContent.viewOnce = true;
881
+ }
882
+ return sock.sendMessage(jid, messageContent, sendOpts);
822
883
  })(), sock, store, { cooldown, jitter });
823
884
  },
824
885
  sticker(source) {
@@ -953,32 +1014,76 @@ function buildEventsApi(sock, pluginName) {
953
1014
  * @param {WASocket} sock
954
1015
  * @param {string|null} chatJid — raw JID of the current group (null in setup context)
955
1016
  */
956
- function buildAdminApi(sock, chatJid) {
957
- /**
958
- * Normalize a participant identifier to the wire JID format WhatsApp's
959
- * servers require (`...@s.whatsapp.net`). `groupParticipantsUpdate()`
960
- * doesn't fail per-participant for a malformed entry the WHOLE IQ
961
- * query gets rejected by the server as a "bad-request" Boom error
962
- * before any per-participant status even exists. This silently broke
963
- * for the two most common inputs a plugin author reaches for:
964
- * - `contact.id` this framework's own normalized `@c.us` form
965
- * (see normalizeJid()/denormalizeJid()), which isn't wire-valid.
966
- * - a bare phone number (with or without "+", spaces or dashes),
967
- * which has no `@...` server segment at all.
968
- * Already-correct JIDs (`@s.whatsapp.net`, `@lid`, `@g.us`) pass through
969
- * unchanged.
970
- * @param {string} id
971
- */
972
- function toParticipantJid(id) {
973
- const trimmed = id.trim();
974
- if (/@(s\.whatsapp\.net|lid|g\.us)$/.test(trimmed))
975
- return trimmed;
976
- if (trimmed.endsWith("@c.us"))
977
- return denormalizeJid(trimmed);
978
- const digits = trimmed.replace(/\D/g, "");
979
- return `${digits}@s.whatsapp.net`;
1017
+ /**
1018
+ * Normalize any identifier to the wire JID format WhatsApp's servers and
1019
+ * protocol actually expect (`...@s.whatsapp.net`). Used anywhere a JID is
1020
+ * handed straight to Baileys — `groupParticipantsUpdate()` and message
1021
+ * `mentions` both of which silently misbehave otherwise: a malformed
1022
+ * group-update JID gets the whole IQ query rejected ("bad-request"), and
1023
+ * a malformed mentions entry just doesn't tag/notify anyone (the message
1024
+ * still sends fine, so it looks like nothing is wrong until you check).
1025
+ * Handles this framework's own normalized `@c.us` form (`contact.id`,
1026
+ * `getMsgSender()`'s output — see normalizeJid()/denormalizeJid()), a bare
1027
+ * phone number (with or without "+", spaces or dashes), and already-valid
1028
+ * wire JIDs (`@s.whatsapp.net`, `@lid`, `@g.us`), which pass through
1029
+ * unchanged.
1030
+ * @param {string} id
1031
+ */
1032
+ function toWireJid(id) {
1033
+ const trimmed = id.trim();
1034
+ if (/@(s\.whatsapp\.net|lid|g\.us)$/.test(trimmed))
1035
+ return trimmed;
1036
+ if (trimmed.endsWith("@c.us"))
1037
+ return denormalizeJid(trimmed);
1038
+ const digits = trimmed.replace(/\D/g, "");
1039
+ return `${digits}@s.whatsapp.net`;
1040
+ }
1041
+ /**
1042
+ * Resolve mentions to the exact JID form the destination group uses for
1043
+ * that participant — not whatever store.resolveJid() happens to have
1044
+ * mapped it to. A group can address a member via @lid even when we've
1045
+ * separately learned their phone number; WhatsApp only tags/notifies a
1046
+ * mention when the JID matches the group's own addressing for that
1047
+ * member. DMs have no participant list to check against, so just wire-
1048
+ * normalize as before (mentions aren't a real thing in DMs anyway).
1049
+ */
1050
+ async function resolveMentionJids(sock, store, jid, mentions) {
1051
+ const result = [];
1052
+ for (const m of mentions) {
1053
+ const wire = toWireJid(m);
1054
+ if (wire)
1055
+ result.push(wire);
1056
+ const resolved = store.resolveJid(m);
1057
+ if (resolved && resolved !== m) {
1058
+ const resolvedWire = toWireJid(resolved);
1059
+ if (resolvedWire)
1060
+ result.push(resolvedWire);
1061
+ }
1062
+ }
1063
+ if (!jid.endsWith("@g.us")) {
1064
+ return Array.from(new Set(result.filter(Boolean)));
980
1065
  }
981
- const norm = (v) => (Array.isArray(v) ? v : [v]).map(toParticipantJid);
1066
+ let meta;
1067
+ try {
1068
+ meta = await getGroupMetadataCached(sock, jid);
1069
+ }
1070
+ catch {
1071
+ return Array.from(new Set(result.filter(Boolean)));
1072
+ }
1073
+ for (const m of mentions) {
1074
+ const wire = toWireJid(m);
1075
+ const resolved = normalizeJid(store.resolveJid(m));
1076
+ const match = meta.participants.find(p => toWireJid(p.id) === wire || normalizeJid(store.resolveJid(p.id)) === resolved);
1077
+ if (match) {
1078
+ const matchWire = toWireJid(match.id);
1079
+ if (matchWire)
1080
+ result.push(matchWire);
1081
+ }
1082
+ }
1083
+ return Array.from(new Set(result.filter(Boolean)));
1084
+ }
1085
+ function buildAdminApi(sock, chatJid) {
1086
+ const norm = (v) => (Array.isArray(v) ? v : [v]).map(toWireJid);
982
1087
  function requireChat() {
983
1088
  if (!chatJid)
984
1089
  throw new Error("This admin operation requires a runtime group context.");
@@ -1351,6 +1456,7 @@ function buildBaseApi(sock, store, pluginRegistry, pluginName) {
1351
1456
  download: buildDownloadApi(),
1352
1457
  scheduler: buildSchedulerApi(pluginName),
1353
1458
  plugins: buildPluginsApi(pluginRegistry),
1459
+ chats: buildChatsApi(store),
1354
1460
  contacts: buildContactsApi(sock, store, botJid),
1355
1461
  storage: buildStorageApi(pluginName),
1356
1462
  botId: botJid,
@@ -1447,6 +1553,19 @@ export function buildApi({ msg, chat, sock, store, pluginRegistry, pluginName, g
1447
1553
  id: normJid,
1448
1554
  name: chat.name,
1449
1555
  isGroup: chat.isGroup,
1556
+ /**
1557
+ * Cached message history for this chat (oldest → newest), capped at
1558
+ * the store's per-chat limit. Supports index access (`history[10]`)
1559
+ * and two chainable filters: `.last(n)` and `.from(senderId)`.
1560
+ * @returns {WAHistoryArray}
1561
+ */
1562
+ get history() {
1563
+ const chatMsgs = store.messages.get(rawJid);
1564
+ const entries = chatMsgs
1565
+ ? [...chatMsgs.values()].map((m) => buildMessageContext(m, sock, store, { cooldown: false, jitter: false }))
1566
+ : [];
1567
+ return makeHistoryArray(entries, store);
1568
+ },
1450
1569
  /**
1451
1570
  * List of group participants.
1452
1571
  * Returns [] for non-group chats.
@@ -31,6 +31,39 @@ let connecting = false;
31
31
  let reconnectAttempts = 0;
32
32
  let cacheHydrated = false;
33
33
  let cacheSaveTimer = null;
34
+ // ── Per-chat message queue ──────────────────────────────────────────────────
35
+ // Messages from the same chat are processed one at a time (in order), but
36
+ // different chats run concurrently — a slow plugin in one chat (e.g. sticker
37
+ // generation) no longer blocks replies in every other chat.
38
+ const chatQueues = new Map();
39
+ function enqueueForChat(jid, task) {
40
+ const prev = chatQueues.get(jid) ?? Promise.resolve();
41
+ const settled = prev.catch(() => { }).then(task).catch((e) => {
42
+ const err = e instanceof Error ? e : new Error(String(e));
43
+ logger.error(`${err.message}\n${err.stack}`);
44
+ });
45
+ chatQueues.set(jid, settled);
46
+ settled.finally(() => {
47
+ if (chatQueues.get(jid) === settled)
48
+ chatQueues.delete(jid);
49
+ });
50
+ }
51
+ // Messages older than this (WhatsApp's own delivery delay — e.g. backlog
52
+ // dumped after the bot reconnects) are skipped. Checked at arrival time,
53
+ // so time spent waiting in chatQueues never counts against a message.
54
+ const MAX_MESSAGE_AGE_SECONDS = 60;
55
+ function isMessageStale(msg) {
56
+ const msgTimestamp = Number(msg.messageTimestamp);
57
+ if (!msgTimestamp)
58
+ return false;
59
+ const nowInSeconds = Math.floor(Date.now() / 1000);
60
+ const age = nowInSeconds - msgTimestamp;
61
+ if (age > MAX_MESSAGE_AGE_SECONDS) {
62
+ logger.debug(`[whatsapp] Skipping stale message (age: ${age}s, id: ${msg.key.id})`);
63
+ return true;
64
+ }
65
+ return false;
66
+ }
34
67
  const RECONNECT_BASE_MS = 1000;
35
68
  const RECONNECT_MAX_MS = 60000;
36
69
  const CACHE_SAVE_INTERVAL_MS = 5 * 60 * 1000; // 5min
@@ -156,13 +189,10 @@ async function startBot() {
156
189
  const body = getBodyQuick(m);
157
190
  if (!body && !msgHasMediaQuick(m))
158
191
  continue;
159
- try {
160
- await handleMessage(m, sock, store);
161
- }
162
- catch (e) {
163
- const err = e instanceof Error ? e : new Error(String(e));
164
- logger.error(`${err.message}\n${err.stack}`);
165
- }
192
+ if (isMessageStale(m))
193
+ continue;
194
+ const jid = normalizeJid(m.key.remoteJid ?? "");
195
+ enqueueForChat(jid, () => handleMessage(m, sock, store));
166
196
  }
167
197
  });
168
198
  }
@@ -45,14 +45,6 @@ function alreadyProcessed(id) {
45
45
  * @param {WAStore} store
46
46
  */
47
47
  export async function handleMessage(msg, sock, store) {
48
- const msgTimestamp = Number(msg.messageTimestamp);
49
- if (msgTimestamp) {
50
- const nowInSeconds = Math.floor(Date.now() / 1000);
51
- const messageAge = nowInSeconds - msgTimestamp;
52
- if (messageAge > 60) {
53
- return;
54
- }
55
- }
56
48
  const rawJid = msg.key.remoteJid ?? "";
57
49
  const jid = normalizeJid(rawJid);
58
50
  if (CHATS.length > 0 && !CHATS.includes(jid)) {
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "5.3.2",
8
+ "version": "5.4.0",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {