@manybot/manybot 5.4.1 → 5.5.1

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.
@@ -26,11 +26,15 @@ function denormalizeJid(jid) {
26
26
  return jid.replace(/@c\.us$/, "@s.whatsapp.net");
27
27
  }
28
28
  import { mkdirSync } from "fs";
29
- import { readFile, writeFile } from "fs/promises";
29
+ import { readFile, writeFile, unlink, mkdtemp, rm } from "fs/promises";
30
30
  import { readFileSync } from "fs";
31
31
  import path from "path";
32
+ import os from "os";
33
+ import { spawn } from "child_process";
34
+ import { randomUUID } from "crypto";
32
35
  import { waitForSendSlot, simulateState, typingDuration, mediaDuration } from "#sendguard";
33
36
  import { buildSettingsApi } from "#settingsdb";
37
+ import WebP from "node-webpmux";
34
38
  import { downloadMediaMessage, getAggregateVotesInPollMessage, decryptPollVote, jidNormalizedUser, normalizeMessageContent } from "@whiskeysockets/baileys";
35
39
  // ── Message body / type helpers ───────────────────────────────────────────────
36
40
  // WhatsApp wraps media in ephemeralMessage/viewOnceMessage/etc when sent as
@@ -206,6 +210,26 @@ const MIME_MAP = {
206
210
  function mimeFromPath(filePath) {
207
211
  return MIME_MAP[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
208
212
  }
213
+ // ── Media source resolution (path or Buffer) ────────────────────────────────
214
+ /**
215
+ * Resolves a media source to a Buffer. Every ctx.send.* / msg.reply.*
216
+ * media method accepts either a filesystem path (string) or an
217
+ * already-loaded Buffer — this is what auto-detects which one it got.
218
+ */
219
+ async function resolveMediaBuffer(source) {
220
+ return Buffer.isBuffer(source) ? source : await readFile(source);
221
+ }
222
+ const GIF_MAGIC = Buffer.from("GIF8");
223
+ /** True if the buffer's magic bytes identify it as a raw .gif image. */
224
+ function isGifBuffer(buffer) {
225
+ return buffer.length >= 4 && buffer.subarray(0, 4).equals(GIF_MAGIC);
226
+ }
227
+ /** True if a gif() source needs ffmpeg conversion to mp4 before sending. */
228
+ function needsGifConversion(source) {
229
+ return Buffer.isBuffer(source)
230
+ ? isGifBuffer(source)
231
+ : path.extname(source).toLowerCase() === ".gif";
232
+ }
209
233
  // ── Storage API ───────────────────────────────────────────────────────────────
210
234
  export function buildStorageApi(pluginName) {
211
235
  if (typeof pluginName !== "string" || pluginName.trim() === "") {
@@ -435,7 +459,6 @@ function buildContactsApi(sock, store, botJid) {
435
459
  * Get a normalized contact object by JID.
436
460
  * @param {string} contactId
437
461
  * @param {{groupId?: string}} [opts] — when `contactId` is a raw `@lid`, this
438
-
439
462
  * always cross-checks it against Baileys' own protocol-level
440
463
  * `sock.signalRepository.lidMapping` first (populated from real Signal
441
464
  * session/identity resolution — not a heuristic, and doesn't need a
@@ -652,11 +675,16 @@ export function buildMessageContext(msg, sock, store, guardOptions = {}) {
652
675
  },
653
676
  hasMedia: msgHasMedia(msg),
654
677
  isGif: msgIsGif(msg),
655
- async downloadMedia() {
678
+ async downloadMedia(opts = {}) {
656
679
  try {
657
680
  const buffer = await downloadMediaMessage(msg, "buffer", {});
658
681
  if (!buffer || !Buffer.isBuffer(buffer))
659
682
  return null;
683
+ const isAnimatedSticker = !!unwrap(msg)?.stickerMessage?.isAnimated;
684
+ if (opts.asMp4 && isAnimatedSticker) {
685
+ const mp4 = await stickerToMp4(buffer);
686
+ return { mimetype: "video/mp4", data: mp4.toString("base64") };
687
+ }
660
688
  return { mimetype: getMsgMimetype(msg), data: buffer.toString("base64") };
661
689
  }
662
690
  catch {
@@ -678,6 +706,12 @@ export function buildMessageContext(msg, sock, store, guardOptions = {}) {
678
706
  return sock.sendMessage(rawJid, { delete: msg.key });
679
707
  }
680
708
  },
709
+ async edit(text) {
710
+ if (!msg.key.fromMe) {
711
+ throw new Error("[pluginApi] edit() can only be used on the bot's own messages");
712
+ }
713
+ return sock.sendMessage(rawJid, { text, edit: msg.key });
714
+ },
681
715
  async pin(_duration) {
682
716
  logger.warn("[pluginApi] pin() is not supported with Baileys");
683
717
  },
@@ -764,8 +798,141 @@ class MessageHandle {
764
798
  react: { text: emoji, key: msg.key },
765
799
  });
766
800
  }
801
+ /** Edit the sent message's text. Only works on the bot's own messages. */
802
+ async edit(text) {
803
+ const msg = await this.rawPromise;
804
+ if (!msg?.key)
805
+ return;
806
+ if (!msg.key.fromMe) {
807
+ throw new Error("[pluginApi] edit() can only be used on the bot's own messages");
808
+ }
809
+ return this._sock.sendMessage(msg.key.remoteJid, { text, edit: msg.key });
810
+ }
767
811
  }
768
812
  // ── Sender factory ────────────────────────────────────────────────────────────
813
+ function runFfmpeg(args) {
814
+ return new Promise((resolve, reject) => {
815
+ const proc = spawn("ffmpeg", args, { stdio: "ignore" });
816
+ proc.on("error", (err) => {
817
+ if (err.code === "ENOENT") {
818
+ reject(new Error("ffmpeg not found on PATH — required to send .gif files. Install ffmpeg, or pass an already mp4-encoded file to gif()."));
819
+ }
820
+ else {
821
+ reject(err);
822
+ }
823
+ });
824
+ proc.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code}`))));
825
+ });
826
+ }
827
+ /**
828
+ * Converts a raw .gif to an mp4 WhatsApp can actually decode as a video —
829
+ * sending GIF89a bytes labeled as video/mp4 leaves the client unable to
830
+ * play it (blurred placeholder + download button, no inline loop).
831
+ */
832
+ async function gifToMp4(gifSource) {
833
+ const outPath = path.join(os.tmpdir(), `${randomUUID()}.mp4`);
834
+ const isBuffer = Buffer.isBuffer(gifSource);
835
+ const inPath = isBuffer ? path.join(os.tmpdir(), `${randomUUID()}.gif`) : gifSource;
836
+ try {
837
+ if (isBuffer)
838
+ await writeFile(inPath, gifSource);
839
+ await runFfmpeg([
840
+ "-y",
841
+ "-i", inPath,
842
+ "-movflags", "faststart",
843
+ "-pix_fmt", "yuv420p",
844
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
845
+ outPath,
846
+ ]);
847
+ return await readFile(outPath);
848
+ }
849
+ finally {
850
+ await unlink(outPath).catch(() => { });
851
+ if (isBuffer)
852
+ await unlink(inPath).catch(() => { });
853
+ }
854
+ }
855
+ /**
856
+ * Converts an animated-sticker WebP buffer to mp4.
857
+ *
858
+ * ffmpeg's own "webp" decoder cannot read animated WebP at all — it
859
+ * silently skips the ANIM/ANMF chunks ("skipping unsupported chunk: ANIM",
860
+ * "image data not found") and the whole conversion fails. This is a
861
+ * long-standing ffmpeg limitation (still true as of ffmpeg-devel
862
+ * discussion in mid-2025: "we have no [animated webp] decoder"), not
863
+ * anything specific to this bot — passing an animated .webp straight to
864
+ * `ffmpeg -i` always fails this way, no matter what you ask it to output.
865
+ *
866
+ * Workaround: use node-webpmux (already a project dependency, bundles its
867
+ * own libwebp via WASM) to demux the animation into individual
868
+ * single-frame WebP files — those decode fine with ffmpeg, since only the
869
+ * ANIM *container* trips it up, not the WebP codec itself — then hand
870
+ * ffmpeg a concat list with each frame's real delay so timing survives.
871
+ *
872
+ * Caveat: this assumes each ANMF frame is a full-canvas frame, which is
873
+ * true for the vast majority of sticker-maker output. Stickers authored
874
+ * with partial-frame deltas (a blend region smaller than the canvas)
875
+ * won't composite correctly here — that would need full RGBA canvas
876
+ * compositing via getFrameData() per frame, which isn't implemented.
877
+ */
878
+ async function stickerToMp4(webpBuffer) {
879
+ const img = new WebP.Image();
880
+ await img.load(webpBuffer);
881
+ const outPath = path.join(os.tmpdir(), `${randomUUID()}.mp4`);
882
+ const scaleFilter = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
883
+ if (!img.hasAnim) {
884
+ // Not actually animated — a plain static webp decodes fine on its own.
885
+ const inPath = path.join(os.tmpdir(), `${randomUUID()}.webp`);
886
+ try {
887
+ await writeFile(inPath, webpBuffer);
888
+ await runFfmpeg([
889
+ "-y",
890
+ "-i", inPath,
891
+ "-movflags", "faststart",
892
+ "-pix_fmt", "yuv420p",
893
+ "-vf", scaleFilter,
894
+ outPath,
895
+ ]);
896
+ return await readFile(outPath);
897
+ }
898
+ finally {
899
+ await unlink(inPath).catch(() => { });
900
+ await unlink(outPath).catch(() => { });
901
+ }
902
+ }
903
+ const dir = await mkdtemp(path.join(os.tmpdir(), "sticker-"));
904
+ try {
905
+ const frameBuffers = await img.demux({ buffers: true });
906
+ const delays = (img.frames ?? []).map(f => (f.delay > 0 ? f.delay : 100)); // ms; WebP spec treats 0 as implementation-defined
907
+ const listLines = [];
908
+ for (let i = 0; i < frameBuffers.length; i++) {
909
+ const framePath = path.join(dir, `frame_${i}.webp`);
910
+ await writeFile(framePath, frameBuffers[i]);
911
+ listLines.push(`file '${framePath}'`);
912
+ listLines.push(`duration ${((delays[i] ?? 100) / 1000).toFixed(3)}`);
913
+ }
914
+ // The concat demuxer ignores the final `duration` line unless the last
915
+ // file is listed once more after it.
916
+ listLines.push(`file '${path.join(dir, `frame_${frameBuffers.length - 1}.webp`)}'`);
917
+ const listPath = path.join(dir, "list.txt");
918
+ await writeFile(listPath, listLines.join("\n"));
919
+ await runFfmpeg([
920
+ "-y",
921
+ "-f", "concat", "-safe", "0",
922
+ "-i", listPath,
923
+ "-vsync", "vfr",
924
+ "-pix_fmt", "yuv420p",
925
+ "-vf", scaleFilter,
926
+ "-movflags", "faststart",
927
+ outPath,
928
+ ]);
929
+ return await readFile(outPath);
930
+ }
931
+ finally {
932
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
933
+ await unlink(outPath).catch(() => { });
934
+ }
935
+ }
769
936
  /**
770
937
  * Returns send methods bound to a specific JID.
771
938
  *
@@ -809,13 +976,13 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
809
976
  return sock.sendMessage(jid, messageContent, sendOpts);
810
977
  })(), sock, store, { cooldown, jitter });
811
978
  },
812
- image(filePath, caption = "", opts = {}) {
979
+ image(source, caption = "", opts = {}) {
813
980
  return new MessageHandle((async () => {
814
981
  const quotedMsg = await resolveQuoted();
815
982
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
816
983
  await waitForSendSlot(normJid, { cooldown, jitter });
817
984
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
818
- const buffer = await readFile(filePath);
985
+ const buffer = await resolveMediaBuffer(source);
819
986
  // viewOnce/mentions are part of the message CONTENT — see note above.
820
987
  const messageContent = { image: buffer, caption };
821
988
  if (opts.viewOnce) {
@@ -827,13 +994,13 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
827
994
  return sock.sendMessage(jid, messageContent, sendOpts);
828
995
  })(), sock, store, { cooldown, jitter });
829
996
  },
830
- video(filePath, caption = "", opts = {}) {
997
+ video(source, caption = "", opts = {}) {
831
998
  return new MessageHandle((async () => {
832
999
  const quotedMsg = await resolveQuoted();
833
1000
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
834
1001
  await waitForSendSlot(normJid, { cooldown, jitter });
835
1002
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
836
- const buffer = await readFile(filePath);
1003
+ const buffer = await resolveMediaBuffer(source);
837
1004
  // viewOnce/mentions are part of the message CONTENT — see note above.
838
1005
  const messageContent = { video: buffer, caption };
839
1006
  if (opts.viewOnce) {
@@ -848,15 +1015,20 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
848
1015
  /**
849
1016
  * Send a GIF. WhatsApp has no native GIF format — this sends an mp4
850
1017
  * with the `gifPlayback` flag, which the client auto-loops, muted.
851
- * `filePath` must already be mp4-encoded (no .gif input, no conversion).
1018
+ * Accepts a path or a Buffer. `.gif` input (detected by extension for
1019
+ * a path, or by magic bytes for a Buffer) is converted to mp4 via
1020
+ * ffmpeg automatically; already mp4-encoded input is sent as-is, no
1021
+ * conversion cost.
852
1022
  */
853
- gif(filePath, caption = "", opts = {}) {
1023
+ gif(source, caption = "", opts = {}) {
854
1024
  return new MessageHandle((async () => {
855
1025
  const quotedMsg = await resolveQuoted();
856
1026
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
857
1027
  await waitForSendSlot(normJid, { cooldown, jitter });
858
1028
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
859
- const buffer = await readFile(filePath);
1029
+ const buffer = needsGifConversion(source)
1030
+ ? await gifToMp4(source)
1031
+ : await resolveMediaBuffer(source);
860
1032
  const messageContent = { video: buffer, caption, gifPlayback: true };
861
1033
  if (opts.viewOnce) {
862
1034
  messageContent.viewOnce = true;
@@ -867,13 +1039,13 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
867
1039
  return sock.sendMessage(jid, messageContent, sendOpts);
868
1040
  })(), sock, store, { cooldown, jitter });
869
1041
  },
870
- audio(filePath, { asVoice = true, viewOnce = false } = {}) {
1042
+ audio(source, { asVoice = true, viewOnce = false } = {}) {
871
1043
  return new MessageHandle((async () => {
872
1044
  const quotedMsg = await resolveQuoted();
873
1045
  const sendOpts = quotedMsg ? { quoted: quotedMsg } : {};
874
1046
  await waitForSendSlot(normJid, { cooldown, jitter });
875
1047
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "recording");
876
- const buffer = await readFile(filePath);
1048
+ const buffer = await resolveMediaBuffer(source);
877
1049
  // viewOnce is part of the message CONTENT — see note above.
878
1050
  const messageContent = { audio: buffer, mimetype: "audio/mp4", ptt: asVoice };
879
1051
  if (viewOnce) {
@@ -888,22 +1060,28 @@ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter =
888
1060
  const qOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
889
1061
  await waitForSendSlot(normJid, { cooldown, jitter });
890
1062
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
891
- const buffer = Buffer.isBuffer(source) ? source : await readFile(source);
1063
+ const buffer = await resolveMediaBuffer(source);
892
1064
  return sock.sendMessage(jid, { sticker: buffer }, qOpts);
893
1065
  })(), sock, store, { cooldown, jitter });
894
1066
  },
895
- file(filePath, filename) {
1067
+ file(source, filename) {
896
1068
  return new MessageHandle((async () => {
897
1069
  const quotedMsg = await resolveQuoted();
898
1070
  const qOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
899
1071
  await waitForSendSlot(normJid, { cooldown, jitter });
900
1072
  await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
901
- const buffer = await readFile(filePath);
902
- const mimetype = mimeFromPath(filePath);
1073
+ const isBuffer = Buffer.isBuffer(source);
1074
+ const buffer = await resolveMediaBuffer(source);
1075
+ // With a path we can always infer the mimetype/name from it. With a
1076
+ // raw Buffer there's no extension to read, so an explicit `filename`
1077
+ // is what lets us infer the mimetype too — otherwise we fall back to
1078
+ // a generic octet-stream document named "file".
1079
+ const mimetype = filename ? mimeFromPath(filename) : (isBuffer ? "application/octet-stream" : mimeFromPath(source));
1080
+ const resolvedFilename = filename ?? (isBuffer ? "file" : path.basename(source));
903
1081
  return sock.sendMessage(jid, {
904
1082
  document: buffer,
905
1083
  mimetype,
906
- fileName: filename ?? path.basename(filePath),
1084
+ fileName: resolvedFilename,
907
1085
  }, qOpts);
908
1086
  })(), sock, store, { cooldown, jitter });
909
1087
  },
@@ -938,12 +1116,12 @@ function buildSendApi(sock, store, rawJid, guardOptions = {}) {
938
1116
  return {
939
1117
  send: {
940
1118
  text: (text, opts) => current.text(text, opts),
941
- image: (filePath, caption, opts) => current.image(filePath, caption, opts),
942
- video: (filePath, caption, opts) => current.video(filePath, caption, opts),
943
- gif: (filePath, caption, opts) => current.gif(filePath, caption, opts),
944
- audio: (filePath, opts) => current.audio(filePath, opts),
1119
+ image: (source, caption, opts) => current.image(source, caption, opts),
1120
+ video: (source, caption, opts) => current.video(source, caption, opts),
1121
+ gif: (source, caption, opts) => current.gif(source, caption, opts),
1122
+ audio: (source, opts) => current.audio(source, opts),
945
1123
  sticker: (source) => current.sticker(source),
946
- file: (filePath, filename) => current.file(filePath, filename),
1124
+ file: (source, filename) => current.file(source, filename),
947
1125
  poll: (q, opts, cfg) => current.poll(q, opts, cfg),
948
1126
  /**
949
1127
  * Returns a sender bound to another chat.
@@ -1656,11 +1834,16 @@ export function buildApi({ msg, chat, sock, store, pluginRegistry, pluginName, g
1656
1834
  sock,
1657
1835
  store,
1658
1836
  msg,
1659
- downloadMedia: async () => {
1837
+ downloadMedia: async (opts = {}) => {
1660
1838
  try {
1661
1839
  const buffer = await downloadMediaMessage(msg, "buffer", {});
1662
1840
  if (!buffer || !Buffer.isBuffer(buffer))
1663
1841
  return null;
1842
+ const isAnimatedSticker = !!unwrap(msg)?.stickerMessage?.isAnimated;
1843
+ if (opts.asMp4 && isAnimatedSticker) {
1844
+ const mp4 = await stickerToMp4(buffer);
1845
+ return { mimetype: "video/mp4", data: mp4.toString("base64") };
1846
+ }
1664
1847
  return { mimetype: getMsgMimetype(msg), data: buffer.toString("base64") };
1665
1848
  }
1666
1849
  catch {
@@ -4,7 +4,7 @@
4
4
  * Creates and returns a Baileys WASocket.
5
5
  * Handles auth state persistence, QR/pairing-code display, and reconnection.
6
6
  */
7
- import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, proto, } from "@whiskeysockets/baileys";
7
+ import { makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, WAProto, } from "@whiskeysockets/baileys";
8
8
  import path from "path";
9
9
  import qrcode from "qrcode-terminal";
10
10
  import { CONFIG_DIR, CLIENT_ID } from "#config";
@@ -57,7 +57,7 @@ export async function createSocket(authDirName = CLIENT_ID) {
57
57
  // https://github.com/WhiskeySockets/Baileys — SocketConfig docs).
58
58
  // This keeps the initial/recent sync (needed for store.chats and
59
59
  // LID→phone resolution) while still skipping the full download.
60
- shouldSyncHistoryMessage: ({ syncType }) => syncType !== proto.HistorySync.HistorySyncType.FULL,
60
+ shouldSyncHistoryMessage: ({ syncType }) => syncType !== WAProto.HistorySync.HistorySyncType.FULL,
61
61
  // Required for Baileys to decrypt incoming poll votes (and to retry
62
62
  // sends) — it looks up the original message by key internally.
63
63
  // Without this, pollUpdates never resolve even though the vote event
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "5.4.1",
8
+ "version": "5.5.1",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@clack/prompts": "^0.10.1",
39
39
  "@hapi/boom": "^10.0.1",
40
- "@whiskeysockets/baileys": "^6.7.23",
40
+ "@whiskeysockets/baileys": "^6.17.16",
41
41
  "node-cron": "^4.6.0",
42
42
  "node-webpmux": "^3.2.1",
43
43
  "pino": "^10.3.1",