morpho-socket 0.0.0-stage → 0.2.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 (59) hide show
  1. package/README.md +155 -2
  2. package/docs/building-a-bot.md +402 -0
  3. package/package.json +25 -4
  4. package/packages/commands/README.md +78 -0
  5. package/packages/commands/antiflood.js +39 -0
  6. package/packages/commands/index.js +116 -0
  7. package/packages/commands/package.json +1 -0
  8. package/packages/context/README.md +97 -0
  9. package/packages/context/group.js +245 -0
  10. package/packages/context/index.js +132 -0
  11. package/packages/context/package.json +1 -0
  12. package/packages/context/participants.js +12 -0
  13. package/packages/context/send.js +127 -0
  14. package/packages/core/README.md +50 -0
  15. package/packages/core/baileys.js +25 -0
  16. package/packages/core/codec.js +17 -0
  17. package/packages/core/index.js +86 -0
  18. package/packages/core/package.json +7 -0
  19. package/packages/core/store.js +11 -0
  20. package/packages/groups/README.md +42 -0
  21. package/packages/groups/index.js +205 -0
  22. package/packages/groups/package.json +7 -0
  23. package/packages/interactive/README.md +41 -0
  24. package/packages/interactive/index.js +92 -0
  25. package/packages/interactive/package.json +1 -0
  26. package/packages/jid/README.md +51 -0
  27. package/packages/jid/index.js +285 -0
  28. package/packages/jid/package.json +7 -0
  29. package/packages/kv/README.md +45 -0
  30. package/packages/kv/index.js +164 -0
  31. package/packages/kv/package.json +8 -0
  32. package/packages/media/README.md +70 -0
  33. package/packages/media/cache.js +116 -0
  34. package/packages/media/erros.js +45 -0
  35. package/packages/media/find.js +51 -0
  36. package/packages/media/index.js +261 -0
  37. package/packages/media/package.json +8 -0
  38. package/packages/message/README.md +53 -0
  39. package/packages/message/index.js +317 -0
  40. package/packages/message/package.json +7 -0
  41. package/packages/pool/README.md +75 -0
  42. package/packages/pool/index.js +369 -0
  43. package/packages/pool/package.json +8 -0
  44. package/packages/presence/README.md +40 -0
  45. package/packages/presence/index.js +80 -0
  46. package/packages/presence/package.json +1 -0
  47. package/packages/reply/README.md +36 -0
  48. package/packages/reply/index.js +126 -0
  49. package/packages/reply/package.json +7 -0
  50. package/packages/reply/voice.js +32 -0
  51. package/packages/sent/README.md +27 -0
  52. package/packages/sent/index.js +65 -0
  53. package/packages/sent/package.json +1 -0
  54. package/packages/store-file/README.md +28 -0
  55. package/packages/store-file/index.js +63 -0
  56. package/packages/store-file/package.json +8 -0
  57. package/packages/store-sqlite/README.md +25 -0
  58. package/packages/store-sqlite/index.js +98 -0
  59. package/packages/store-sqlite/package.json +9 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@morpho/kv",
3
+ "version": "0.1.0",
4
+ "description": "Dados do bot num arquivo SQLite só: coleções chave→JSON, transações (transferência de moedas sem perder nada), validade (cooldowns), ranking e backup. Sem dependência externa.",
5
+ "main": "index.js",
6
+ "engines": { "node": ">=22.5.0" },
7
+ "license": "MIT"
8
+ }
@@ -0,0 +1,70 @@
1
+ # morpho-socket/media
2
+
3
+ WhatsApp media download that **does not give up**. It does not depend on Baileys at import time (Baileys is loaded only when a download actually happens, and the downloader can be replaced), so it is testable without WhatsApp.
4
+
5
+ ```js
6
+ const { createMedia } = require("morpho-socket/media")
7
+ const media = createMedia({ disk: { dir: "./cache/media" } })
8
+
9
+ media.bind(msg, sock) // optional, see "Expired links"
10
+ const r = await media.download(msg, { types: ["image", "video"] }) // null when there is no media
11
+ r.buffer; r.type; r.mimetype; r.size; r.fromQuoted; r.source // source: "memory" | "disk" | "network" | "resend"
12
+ ```
13
+
14
+ ## What it solves
15
+
16
+ | Problem | Solution |
17
+ |---|---|
18
+ | Which media is it? | `find(msg, { types, quoted })`: the **replied-to** message's media wins, else the message's own. Understands view-once, ephemeral and document captions. `quoted: "only"` / `"never"` restricts it |
19
+ | **The CDN link expired** (403/404/410) | asks the phone to re-send it (`sock.updateMediaMessage`) **once**, with a time limit (30 s), then downloads again. For a replied-to message it rebuilds the key (id, author, `fromMe`). Several requests for the same message share one |
20
+ | Network failures | retries with a growing wait, only for network errors, 429 and 5xx (2 retries). Invalid key / failed decryption gives up at once |
21
+ | Huge files | refused **before** downloading (declared size) and **while** downloading (bytes received); the stream is closed |
22
+ | Stalled downloads | per-attempt timeout (45 s); the stream is closed |
23
+ | Download bursts | at most 4 at a time, the rest queue |
24
+ | Same media again | memory cache (LRU by **bytes**) and optional disk cache. The key is the file hash, so the same sticker re-sent by someone else is a hit. Concurrent identical requests become one |
25
+
26
+ ## Expired links: how it works
27
+
28
+ `updateMediaMessage` needs `{ key, message }` and the **socket that received the message**. `find()` builds that pair for you. Give it the socket on each call (`download(msg, { sock })`) or once per message with `bind(msg, sock)` — handy when a command runs without the socket at hand ([`context`](../context) does the binding for you).
29
+
30
+ Re-sending is **best effort**: the phone has to be online and still have the message. If it can't, the error is `MEDIA_EXPIRED`. The disk cache is the way out for heavily used media: once downloaded, it no longer depends on the link.
31
+
32
+ ## Privacy
33
+
34
+ - **View-once media is never cached** (memory or disk) unless you set `cacheViewOnce: true`.
35
+ - The disk cache stores **decrypted** media: directory `0700`, files `0600`, 24 h expiry and a size cap (256 MB). Keep it out of folders that get backed up or zipped.
36
+
37
+ ## Errors
38
+
39
+ `MediaError` with a stable `code` (the original error is in `e.cause`):
40
+
41
+ | `code` | Meaning |
42
+ |---|---|
43
+ | `MEDIA_TOO_LARGE` | over the size limit (declared or received) |
44
+ | `MEDIA_EXPIRED` | link expired and could not be recovered (phone offline, message deleted…) |
45
+ | `MEDIA_TIMEOUT` | took too long, even after retries |
46
+ | `MEDIA_NETWORK` | network/server failure even after retries |
47
+ | `MEDIA_INVALID` | message without key/URL, or content that does not decrypt (retrying won't help) |
48
+ | `MEDIA_UNAVAILABLE` | anything else |
49
+
50
+ ## Options
51
+
52
+ `createMedia({ … })`
53
+
54
+ | Option | Default |
55
+ |---|---|
56
+ | `downloader(node, type)` | Baileys' `downloadContentFromMessage` |
57
+ | `max` | 50 MB |
58
+ | `timeoutMs` | 45 s per attempt |
59
+ | `retries` / `backoff(n)` | 2 / 400 ms × 3ⁿ |
60
+ | `reuploadTimeoutMs` | 30 s |
61
+ | `concurrency` | 4 |
62
+ | `cache` | `false`, or `{ maxBytes, maxEntry, ttlMs }` — default 64 MB / 8 MB per entry / 10 min |
63
+ | `disk` | off; `{ dir, maxBytes, maxEntry, ttlMs }` — 256 MB / 32 MB / 24 h |
64
+ | `cacheViewOnce` | `false` |
65
+ | `copy` | `true` — hand out a copy of cached buffers so mutating one can't corrupt the cache |
66
+ | `log(event, data)` | `"resend"`, `"retry"`, `"disk-cache-error"` |
67
+
68
+ Methods: `find` · `download(msgOrFound, opts)` (opts: `sock`, `max`, `timeoutMs`, `cache`, `reupload`, plus `types`/`quoted`) · `downloadFound` · `prefetch(msg)` (warms the cache with something that just arrived; never throws) · `bind` · `stats()` · `clearCache()`.
69
+
70
+ Also exported: `MediaError`, `classify`, `find`, `DEFAULT_MAX`, `createMemoryCache`, `createDiskCache`.
@@ -0,0 +1,116 @@
1
+ // @morpho/media — cache de mídia já decifrada. Duas camadas, ambas opcionais e independentes:
2
+ // memória: LRU limitado por BYTES (não por quantidade), com validade. Some ao reiniciar.
3
+ // disco: sobrevive a reinício e é a que resolve de verdade o link expirado das mídias QUENTES (a figurinha que o
4
+ // grupo inteiro usa): baixou uma vez, não depende mais do WhatsApp. Limitado por bytes e validade.
5
+ // Atenção: o disco guarda a mídia DECIFRADA dos usuários. Pasta com permissão só do dono (0700/0600), e fora de pastas
6
+ // que entram em backup ou zip.
7
+ const fs = require("fs")
8
+ const fsp = fs.promises
9
+ const path = require("path")
10
+ const crypto = require("crypto")
11
+
12
+ const MB = 1024 * 1024
13
+
14
+ function createMemoryCache({ maxBytes = 64 * MB, maxEntry = 8 * MB, ttlMs = 10 * 60e3, now = Date.now } = {}) {
15
+ const items = new Map() // chave -> { buf, em } (ordem de inserção = ordem de uso: o mais antigo é o primeiro)
16
+ let total = 0
17
+
18
+ const remove = k => { const e = items.get(k); if (e) { total -= e.buf.length; items.delete(k) } }
19
+
20
+ return {
21
+ get(k) {
22
+ const e = items.get(k)
23
+ if (!e) return null
24
+ if (now() - e.at > ttlMs) { remove(k); return null }
25
+ items.delete(k); items.set(k, e) // usado agora: vai pro fim
26
+ return e.buf
27
+ },
28
+ set(k, buf) {
29
+ if (!Buffer.isBuffer(buf) || buf.length > maxEntry || buf.length > maxBytes) return false
30
+ remove(k)
31
+ while (total + buf.length > maxBytes && items.size) remove(items.keys().next().value)
32
+ items.set(k, { buf, at: now() }); total += buf.length
33
+ return true
34
+ },
35
+ clear() { items.clear(); total = 0 },
36
+ stats: () => ({ entries: items.size, bytes: total, maxBytes })
37
+ }
38
+ }
39
+
40
+ function createDiskCache({ dir, maxBytes = 256 * MB, maxEntry = 32 * MB, ttlMs = 24 * 3600e3, now = Date.now } = {}) {
41
+ if (!dir) throw new Error("criarCacheDisco: 'dir' é obrigatório.")
42
+ let total = 0
43
+ let ready = null
44
+ let cleaning = null
45
+
46
+ const file = k => path.join(dir, crypto.createHash("sha256").update(k).digest("hex").slice(0, 40) + ".bin")
47
+
48
+ // lista { f, tamanho, em } de tudo que é cache; apaga sobra de gravação interrompida (.tmp)
49
+ async function list() {
50
+ const out = []
51
+ for (const name of await fsp.readdir(dir).catch(() => [])) {
52
+ const f = path.join(dir, name)
53
+ if (name.endsWith(".tmp")) { await fsp.unlink(f).catch(() => {}); continue }
54
+ if (!name.endsWith(".bin")) continue
55
+ try { const s = await fsp.stat(f); out.push({ f, size: s.size, at: s.mtimeMs }) } catch { /* sumiu no meio */ }
56
+ }
57
+ return out
58
+ }
59
+
60
+ const start = () => ready ||= (async () => {
61
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 })
62
+ total = (await list()).reduce((a, x) => a + x.size, 0)
63
+ })()
64
+
65
+ // tira o vencido e, se ainda passar do limite, os menos usados (mtime = último uso: get() renova)
66
+ const trimExcess = () => cleaning ||= (async () => {
67
+ try {
68
+ let items = await list()
69
+ for (const x of items.filter(x => now() - x.at > ttlMs)) await fsp.unlink(x.f).catch(() => {})
70
+ items = items.filter(x => now() - x.at <= ttlMs).sort((a, b) => a.at - b.at)
71
+ total = items.reduce((a, x) => a + x.size, 0)
72
+ for (const x of items) {
73
+ if (total <= maxBytes * 0.9) break
74
+ await fsp.unlink(x.f).catch(() => {}); total -= x.size
75
+ }
76
+ } finally { cleaning = null }
77
+ })()
78
+
79
+ return {
80
+ async get(k) {
81
+ await start()
82
+ const f = file(k)
83
+ try {
84
+ const s = await fsp.stat(f)
85
+ if (now() - s.mtimeMs > ttlMs) { await fsp.unlink(f).catch(() => {}); total -= s.size; return null }
86
+ const buf = await fsp.readFile(f)
87
+ const t = new Date(now()); await fsp.utimes(f, t, t).catch(() => {}) // renova o "último uso"
88
+ return buf
89
+ } catch { return null }
90
+ },
91
+ async set(k, buf) {
92
+ if (!Buffer.isBuffer(buf) || buf.length > maxEntry || buf.length > maxBytes) return false
93
+ await start()
94
+ const f = file(k)
95
+ const before = await fsp.stat(f).then(s => s.size, () => 0)
96
+ const tmp = `${f}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`
97
+ try {
98
+ await fsp.writeFile(tmp, buf, { mode: 0o600 })
99
+ await fsp.rename(tmp, f) // atômico: quem lê nunca vê arquivo pela metade
100
+ // "último uso" gravado de propósito: gravações seguidas podem empatar no relógio do sistema de arquivos
101
+ const t = new Date(now()); await fsp.utimes(f, t, t)
102
+ } catch (e) { await fsp.unlink(tmp).catch(() => {}); throw e }
103
+ total += buf.length - before
104
+ if (total > maxBytes) await trimExcess()
105
+ return true
106
+ },
107
+ async clear() {
108
+ await start()
109
+ for (const x of await list()) await fsp.unlink(x.f).catch(() => {})
110
+ total = 0
111
+ },
112
+ stats: () => ({ bytes: total, maxBytes, dir })
113
+ }
114
+ }
115
+
116
+ module.exports = { createMemoryCache, createDiskCache, MB }
@@ -0,0 +1,45 @@
1
+ // @morpho/media — erros com código estável (quem usa decide a mensagem para o usuário).
2
+ //
3
+ // MIDIA_GRANDE passou do limite de tamanho (antes de baixar, pelo tamanho declarado, ou durante, pelo que chegou)
4
+ // MIDIA_EXPIRADA o link do WhatsApp venceu e não deu para recuperar (celular offline, mensagem apagada...)
5
+ // MIDIA_TIMEOUT demorou demais (depois das tentativas)
6
+ // MIDIA_REDE falha de rede/servidor mesmo depois das tentativas
7
+ // MIDIA_INVALIDA mensagem sem chave/endereço, ou conteúdo que não decifra (não adianta tentar de novo)
8
+ // MIDIA_INDISPONIVEL qualquer outro erro (a causa original fica em .cause)
9
+ class MediaError extends Error {
10
+ constructor(code, message, { cause, status } = {}) {
11
+ super(message)
12
+ this.name = "MediaError"
13
+ this.code = code
14
+ if (status) this.status = status
15
+ if (cause) this.cause = cause
16
+ }
17
+ }
18
+
19
+ const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EPIPE", "ENETUNREACH", "EHOSTUNREACH",
20
+ "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "ERR_STREAM_PREMATURE_CLOSE"])
21
+
22
+ // o Baileys lança Boom (output.statusCode); fetch/undici às vezes põe em status; erro de rede cru não tem nenhum
23
+ function statusDe(e) {
24
+ const n = e?.output?.statusCode ?? e?.statusCode ?? e?.status ?? e?.data?.statusCode ?? e?.data?.status ?? null
25
+ return Number.isInteger(n) ? n : null
26
+ }
27
+
28
+ // O que fazer com o erro de UMA tentativa:
29
+ // "expirada" -> pedir ao celular para reenviar a mídia (403/404/410: link vencido ou assinatura expirada)
30
+ // "transitoria" -> tentar de novo depois de uma espera (rede, 429, 5xx, timeout)
31
+ // "invalida" -> desistir (não muda de resultado se repetir)
32
+ // "outro" -> desconhecido: desiste, sem inventar causa
33
+ function classify(e) {
34
+ if (e instanceof MediaError) return e.code === "MEDIA_TIMEOUT" ? "transient" : e.code === "MEDIA_INVALID" ? "invalid" : "other"
35
+ const s = statusDe(e)
36
+ if (s === 403 || s === 404 || s === 410) return "expired"
37
+ if (s === 429 || (s !== null && s >= 500)) return "transient"
38
+ if (s === 400 || s === 401) return "invalid"
39
+ const msg = String(e?.message || "")
40
+ if (NETWORK_CODES.has(e?.code) || NETWORK_CODES.has(e?.cause?.code) || /fetch failed|terminated|socket hang up|network|timed? ?out/i.test(msg)) return "transient"
41
+ if (/bad decrypt|unable to authenticate|unsupported state|invalid (mac|key)|no valid media url|mediakey/i.test(msg)) return "invalid"
42
+ return "other"
43
+ }
44
+
45
+ module.exports = { MediaError, classify, statusDe }
@@ -0,0 +1,51 @@
1
+ // @morpho/media — find the media inside a WhatsApp message.
2
+ //
3
+ // The unwrapping/typing logic lives in @morpho/message (one source of truth). This file only adds what the downloader
4
+ // needs on top of it: a content-based cache key and the { key, message } pair Baileys wants to ask the phone to re-send
5
+ // media whose CDN link expired.
6
+ const { parse, findMedia } = require("../message")
7
+
8
+ // bytes from Baileys (Buffer/Uint8Array) or text -> base64; null for anything else
9
+ function b64(x) {
10
+ if (!x) return null
11
+ if (typeof x === "string") return x
12
+ if (x instanceof Uint8Array) return Buffer.from(x).toString("base64")
13
+ return null
14
+ }
15
+
16
+ // Identity of the CONTENT, used as cache key. The hash of the decrypted file (fileSha256) is the best one: the same
17
+ // sticker re-sent by different people has the same hash. Without it, other identifiers of the same media are used.
18
+ function cacheKeyOf(node, type) {
19
+ const id = b64(node?.fileSha256) || b64(node?.fileEncSha256) || b64(node?.mediaKey) || (typeof node?.directPath === "string" ? node.directPath : null)
20
+ return id ? `${type}:${id}` : null
21
+ }
22
+
23
+ // The message in the shape Baileys needs to request a re-send ({ key, message }):
24
+ // - media from the message itself: the message itself;
25
+ // - media from a reply: builds the quoted message's key (id and author come from the contextInfo).
26
+ // Whether it was fromMe is only known with the sock (see index.js).
27
+ function resendMessageFor(msg, fromQuoted) {
28
+ const m = parse(msg)
29
+ if (!fromQuoted) return m.key?.id && m.chat && msg?.message ? msg : null
30
+ const ci = m.context
31
+ if (!ci?.stanzaId || !ci.quotedMessage) return null
32
+ return {
33
+ key: { remoteJid: ci.remoteJid || m.chat, id: ci.stanzaId, participant: ci.participant || undefined },
34
+ message: ci.quotedMessage
35
+ }
36
+ }
37
+
38
+ // A document only counts when it is really an audio/video/image
39
+ const realMedia = a => a.type !== "document" || a.family !== "other"
40
+
41
+ // types: list of "image" | "video" | "audio" | "sticker" | "document" (default: all)
42
+ // quoted: "prefer" (default: the quoted message's media, else the message's own) | "only" | "never"
43
+ // returns the @morpho/message media object (type, kind, mimetype, seconds, size, ptt, animated, fileName, viewOnce, node, fromQuoted...)
44
+ // plus { msg, resendMessage, cacheKey }, or null.
45
+ function find(msg, { types, quoted = "prefer" } = {}) {
46
+ const media = findMedia(msg, { types, quoted, accept: realMedia })
47
+ if (!media) return null
48
+ return { ...media, msg, resendMessage: resendMessageFor(msg, media.fromQuoted), cacheKey: cacheKeyOf(media.node, media.type) }
49
+ }
50
+
51
+ module.exports = { find, cacheKeyOf, resendMessageFor }
@@ -0,0 +1,261 @@
1
+ // @morpho/media — download de mídia do WhatsApp que não desiste.
2
+ //
3
+ // O que o Baileys sozinho não resolve (e o Morvyn ia refazendo em cada comando):
4
+ // 1. ACHAR a mídia: a da mensagem marcada tem prioridade, senão a da própria; entende visualização única,
5
+ // mensagem temporária, legenda de documento...
6
+ // 2. LINK EXPIRADO: o endereço da mídia no CDN do WhatsApp vence (403/404/410). Em vez de falhar, pede ao celular
7
+ // que reenvie (sock.updateMediaMessage), com limite de tempo, UMA vez, e baixa de novo.
8
+ // 3. LIMITES: tamanho (pelo declarado ANTES de baixar e pelo que realmente chega), tempo e quantidade simultânea.
9
+ // 4. RETRY só onde faz sentido: rede, 429 e 5xx tentam de novo com espera; link vencido pede reenvio; chave/decifra
10
+ // inválida desiste na hora.
11
+ // 5. CACHE: memória (LRU por bytes) e disco (opcional, sobrevive a reinício). A mesma figurinha usada por todo o
12
+ // grupo é baixada uma vez só; pedidos iguais ao mesmo tempo viram UMA consulta.
13
+ //
14
+ // const { criarMidia } = require("@morpho/media")
15
+ // const midia = criarMidia({ disco: { dir: "./cache/media" } })
16
+ // midia.vincular(msg, sock) // opcional: diz de qual sock é a mensagem (para o pedido de reenvio)
17
+ // const r = await midia.baixar(msg, { tipos: ["image", "video"] }) // null se não há mídia
18
+ // r.buffer, r.tipo, r.mimetype, r.origem // origem: "memoria" | "disco" | "rede" | "reenvio"
19
+ //
20
+ // Sem dependência do Baileys no código: o downloader padrão o carrega só quando precisa, e dá para trocar
21
+ // (opção `download`), o que também permite testar sem WhatsApp.
22
+ const { findMedia } = require("../message")
23
+ const { find: findBase } = require("./find")
24
+ const { MediaError, classify, statusDe } = require("./erros")
25
+ const { createMemoryCache, createDiskCache, MB } = require("./cache")
26
+
27
+ const DEFAULT_MAX = 50 * MB
28
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
29
+ const part = j => String(j || "").split("@")[0].split(":")[0]
30
+ const isMine = (sock, participantJid) => !!participantJid && [sock?.user?.id, sock?.user?.lid].filter(Boolean).map(part).includes(part(participantJid))
31
+
32
+ const tooLarge = n => new MediaError("MEDIA_TOO_LARGE", n ? `Mídia grande demais (${Math.round(n / MB)} MB)` : "Mídia grande demais")
33
+
34
+ function withTimeout(promise, ms, onTimeout) {
35
+ return new Promise((resolve, reject) => {
36
+ const t = setTimeout(() => { try { onTimeout?.() } catch { /* já fechado */ } reject(new MediaError("MEDIA_TIMEOUT", `Passou de ${ms}ms`)) }, ms)
37
+ promise.then(v => { clearTimeout(t); resolve(v) }, e => { clearTimeout(t); reject(e) })
38
+ })
39
+ }
40
+
41
+ function createSemaphore(n) {
42
+ let free = n
43
+ const queue = []
44
+ return {
45
+ async acquire() { if (free > 0) { free--; return } await new Promise(r => queue.push(r)) },
46
+ release() { const next = queue.shift(); if (next) next(); else free++ },
47
+ backoff: () => queue.length
48
+ }
49
+ }
50
+
51
+ const baileysDownloader = () => (node, type) => require("../core/baileys").loadBaileys().downloadContentFromMessage(node, type)
52
+
53
+ // converte o erro cru da última tentativa no erro com código estável
54
+ function convert(e, cls) {
55
+ if (e instanceof MediaError) return e
56
+ const status = statusDe(e) || undefined
57
+ if (cls === "transient") return new MediaError("MEDIA_NETWORK", `Falha de rede ao baixar a mídia: ${e?.message || e}`, { cause: e, status })
58
+ if (cls === "invalid") return new MediaError("MEDIA_INVALID", `Mídia inválida: ${e?.message || e}`, { cause: e, status })
59
+ return new MediaError("MEDIA_UNAVAILABLE", `Não consegui baixar a mídia: ${e?.message || e}`, { cause: e, status })
60
+ }
61
+
62
+ // opções:
63
+ // download (no, tipo) => stream | Buffer padrão: downloadContentFromMessage do Baileys
64
+ // max bytes aceitos por download padrão 50 MB (cada chamada pode passar o seu)
65
+ // timeoutMs limite de UMA tentativa padrão 45 s
66
+ // tentativas retries em erro de rede/429/5xx padrão 2 (3 tentativas no total)
67
+ // espera (n) => ms antes do retry n padrão 400ms, 1,2s, 3,6s...
68
+ // reuploadTimeoutMs limite do pedido de reenvio ao celular padrão 30 s
69
+ // concorrencia downloads ao mesmo tempo padrão 4 (o resto espera na fila)
70
+ // cache false | { maxBytes, maxEntrada, ttlMs } memória; padrão 64 MB, entrada até 8 MB, 10 min
71
+ // cacheUmaVez guardar também mídia de "visualização única"? padrão false (privacidade: nunca vai para memória nem disco)
72
+ // disco { dir, maxBytes, maxEntrada, ttlMs } cache em disco (desligado se não passar)
73
+ // copiar entrega cópia do que está no cache (padrão true: quem altera o Buffer não estraga o cache)
74
+ // log (evento, dados) => void "reenvio", "retry", "cache-disco-erro"
75
+ function createMedia({ downloader, max = DEFAULT_MAX, timeoutMs = 45e3, retries = 2, backoff = n => 400 * 3 ** n, reuploadTimeoutMs = 30e3,
76
+ concurrency = 4, cache, disk, cacheViewOnce = false, copy = true, log } = {}) {
77
+
78
+ const fetchBytes = downloader || baileysDownloader()
79
+ const memory = cache === false ? null : createMemoryCache(cache && typeof cache === "object" ? cache : {})
80
+ const onDisk = disk ? createDiskCache(disk) : null
81
+ const semaphore = createSemaphore(Math.max(1, concurrency))
82
+ const bindings = new WeakMap() // objeto da mensagem -> sock que a recebeu
83
+ const pending = new Map() // cacheKey -> Promise (download em andamento: pedidos iguais esperam o mesmo)
84
+ const resendsInFlight = new Map() // id da mensagem -> Promise (um pedido de reenvio por mensagem)
85
+ const st = { memoryHits: 0, diskHits: 0, cacheMisses: 0, downloads: 0, retries: 0, resends: 0, resendsOk: 0, errors: 0, bytesDownloaded: 0 }
86
+ const notify = (event, data) => { try { log?.(event, data) } catch { /* log não pode derrubar o download */ } }
87
+
88
+ // ---------- cache ----------
89
+ async function readCache(key) {
90
+ const m = memory?.get(key)
91
+ if (m) { st.memoryHits++; return { buffer: m, source: "memory" } }
92
+ if (onDisk) {
93
+ try {
94
+ const d = await onDisk.get(key)
95
+ if (d) { memory?.set(key, d); st.diskHits++; return { buffer: d, source: "disk" } }
96
+ } catch (e) { notify("disk-cache-error", e) }
97
+ }
98
+ st.cacheMisses++
99
+ return null
100
+ }
101
+ async function save(key, buf) {
102
+ memory?.set(key, buf)
103
+ if (onDisk) { try { await onDisk.set(key, buf) } catch (e) { notify("disk-cache-error", e) } }
104
+ }
105
+
106
+ // ---------- UMA tentativa de download (com fila, limite de tamanho e de tempo) ----------
107
+ async function attemptOnce(node, type, maxSize, limitMs) {
108
+ await semaphore.acquire()
109
+ let stream
110
+ try {
111
+ const read = (async () => {
112
+ stream = await fetchBytes(node, type)
113
+ if (Buffer.isBuffer(stream) || stream instanceof Uint8Array) {
114
+ const b = Buffer.from(stream)
115
+ if (b.length > maxSize) throw tooLarge(b.length)
116
+ return b
117
+ }
118
+ const chunks = []
119
+ let total = 0
120
+ for await (const p of stream) {
121
+ total += p.length
122
+ if (total > maxSize) throw tooLarge(total) // sair do laço fecha o stream: para de baixar na hora
123
+ chunks.push(p)
124
+ }
125
+ return Buffer.concat(chunks)
126
+ })()
127
+ read.catch(() => {}) // se o tempo estourar primeiro, o erro tardio daqui não pode virar rejeição sem dono
128
+ return await withTimeout(read, limitMs, () => { try { stream?.destroy?.() } catch { /* já fechado */ } })
129
+ } finally { semaphore.release() }
130
+ }
131
+
132
+ // ---------- link expirado: pede ao celular que reenvie a mídia ----------
133
+ // devolve o nó da mídia já com o endereço novo
134
+ function requestResend(found, sock) {
135
+ if (typeof sock?.updateMediaMessage !== "function")
136
+ throw new MediaError("MEDIA_EXPIRED", "O link da mídia expirou e não há como pedir o reenvio (sem o sock).")
137
+ const base = found.resendMessage
138
+ if (!base) throw new MediaError("MEDIA_EXPIRED", "O link da mídia expirou e a mensagem não traz identificador para pedir o reenvio.")
139
+
140
+ const id = `${base.key?.remoteJid}|${base.key?.id}`
141
+ if (resendsInFlight.has(id)) return resendsInFlight.get(id)
142
+
143
+ const p = (async () => {
144
+ st.resends++
145
+ // mídia de uma resposta: o Baileys precisa saber se a citada era do próprio bot
146
+ const msg = found.fromQuoted ? { ...base, key: { ...base.key, fromMe: isMine(sock, base.key?.participant) } } : base
147
+ let r
148
+ try { r = await withTimeout(Promise.resolve(sock.updateMediaMessage(msg)), reuploadTimeoutMs) }
149
+ catch (e) { throw new MediaError("MEDIA_EXPIRED", "O link da mídia expirou e o celular não reenviou a tempo (offline, ou a mensagem foi apagada).", { cause: e }) }
150
+ // o Baileys atualiza a mensagem no lugar; se devolveu outra, lê o endereço novo dela
151
+ const m = r?.message ? r : msg
152
+ st.resendsOk++
153
+ return findMedia({ key: m.key, message: m.message }, { quoted: "never" })?.node || found.node
154
+ })()
155
+ resendsInFlight.set(id, p)
156
+ const clear = () => resendsInFlight.delete(id)
157
+ p.then(clear, clear)
158
+ return p
159
+ }
160
+
161
+ // ---------- baixar de verdade: retry, reenvio, tudo ----------
162
+ async function fetchWithRetry(found, { sock, maxSize, limitMs, reupload }) {
163
+ let node = found.node, resent = false, tries = 0
164
+ for (;;) {
165
+ try {
166
+ st.downloads++
167
+ const buffer = await attemptOnce(node, found.type, maxSize, limitMs)
168
+ st.bytesDownloaded += buffer.length
169
+ return { buffer, source: resent ? "resend" : "network" }
170
+ } catch (e) {
171
+ if (e?.code === "MEDIA_TOO_LARGE") throw e
172
+ const cls = classify(e)
173
+ if (cls === "expired") {
174
+ if (resent || !reupload) throw new MediaError("MEDIA_EXPIRED", "O link da mídia expirou no WhatsApp.", { cause: e, status: statusDe(e) || undefined })
175
+ resent = true
176
+ notify("resend", { type: found.type, status: statusDe(e) })
177
+ node = await requestResend(found, sock)
178
+ continue
179
+ }
180
+ if (cls === "transient" && tries < retries) {
181
+ notify("retry", { attempt: tries + 1, error: e?.message })
182
+ st.retries++
183
+ await sleep(backoff(tries++))
184
+ continue
185
+ }
186
+ throw convert(e, cls)
187
+ }
188
+ }
189
+ }
190
+
191
+ // ---------- API ----------
192
+ const findWithSock = (msg, o) => {
193
+ const a = findBase(msg, o)
194
+ if (a) a.sock = (msg && typeof msg === "object" && bindings.get(msg)) || null
195
+ return a
196
+ }
197
+
198
+ // opções: { sock, max, timeoutMs, cache (false = ignora o cache), reupload (false = não pede reenvio) }
199
+ // devolve { buffer, tipo, mimetype, tamanho, citada, nomeArquivo, segundos, ptt, animado, origem } ou null (achado vazio)
200
+ async function downloadFound(found, opts = {}) {
201
+ if (!found) return null
202
+ const maxSize = opts.max ?? max
203
+ if (found.size > maxSize) throw tooLarge(found.size)
204
+ const sock = opts.sock ?? found.sock ?? (found.msg && typeof found.msg === "object" ? bindings.get(found.msg) : null) ?? null
205
+ const key = opts.cache === false || (found.viewOnce && !cacheViewOnce) ? null : found.cacheKey
206
+
207
+ try {
208
+ let r = key ? await readCache(key) : null
209
+ if (!r) {
210
+ const run = () => fetchWithRetry(found, { sock, maxSize, limitMs: opts.timeoutMs ?? timeoutMs, reupload: opts.reupload !== false })
211
+ if (key) {
212
+ let p = pending.get(key)
213
+ if (!p) {
214
+ p = run().then(async x => { await save(key, x.buffer); return x })
215
+ pending.set(key, p)
216
+ const clear = () => { if (pending.get(key) === p) pending.delete(key) }
217
+ p.then(clear, clear)
218
+ }
219
+ r = await p
220
+ } else r = await run()
221
+ }
222
+ if (r.buffer.length > maxSize) throw tooLarge(r.buffer.length) // o cache/pedido de outro tinha limite maior que o deste chamador
223
+ const buffer = key && copy ? Buffer.from(r.buffer) : r.buffer
224
+ return {
225
+ buffer, type: found.type, mimetype: found.mimetype, size: buffer.length, fromQuoted: found.fromQuoted,
226
+ fileName: found.fileName, seconds: found.seconds, ptt: found.ptt, animated: found.animated, source: r.source
227
+ }
228
+ } catch (e) { st.errors++; throw e }
229
+ }
230
+
231
+ // aceita a mensagem (acha a mídia sozinho) ou um achado de achar(). Opções de achar() (tipos, citada) valem junto.
232
+ function download(target, opts = {}) {
233
+ const found = target && target.node && target.type ? target : findWithSock(target, opts)
234
+ return downloadFound(found, opts)
235
+ }
236
+
237
+ // Guarda no cache uma mídia que ACABOU de chegar, antes de o link ter chance de vencer. Nunca lança (devolve true/false).
238
+ // Não liga sozinho: baixar tudo que chega pesa. Use para o que vale a pena (ex.: figurinhas e imagens pequenas de grupo).
239
+ async function prefetch(msg, { types = ["sticker", "image"], max: maxSize = 2 * MB, ...rest } = {}) {
240
+ try {
241
+ const a = findWithSock(msg, { types, quoted: "never" })
242
+ if (!a?.cacheKey || a.viewOnce || a.size > maxSize) return false
243
+ await downloadFound(a, { ...rest, max: maxSize, reupload: false })
244
+ return true
245
+ } catch { return false }
246
+ }
247
+
248
+ return {
249
+ find: findWithSock,
250
+ download,
251
+ downloadFound,
252
+ prefetch,
253
+ // diz de qual sock é esta mensagem: o pedido de reenvio de mídia expirada precisa dele, e quem chama baixar(msg)
254
+ // de dentro de um comando nem sempre tem o sock à mão
255
+ bind(msg, sock) { if (msg && typeof msg === "object") bindings.set(msg, sock) },
256
+ stats: () => ({ ...st, inFlight: pending.size, queued: semaphore.backoff(), memory: memory?.stats() ?? null, disk: onDisk?.stats() ?? null }),
257
+ async clearCache() { memory?.clear(); await onDisk?.clear() }
258
+ }
259
+ }
260
+
261
+ module.exports = { createMedia, MediaError, classify, find: findBase, DEFAULT_MAX, createMemoryCache, createDiskCache }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@morpho/media",
3
+ "version": "0.2.0",
4
+ "description": "WhatsApp media download that does not give up: finds the media (reply or own), downloads with size limit, timeout and retry, recovers expired-link media by asking the phone to re-send it, and caches it (memory + optional disk).",
5
+ "main": "index.js",
6
+ "dependencies": { "@morpho/message": "file:../message" },
7
+ "license": "MIT"
8
+ }
@@ -0,0 +1,53 @@
1
+ # morpho-socket/message
2
+
3
+ One function that turns a raw Baileys message into a **simple object that always has the same shape**.
4
+
5
+ The raw message hides the real content under wrappers (`ephemeralMessage`, `viewOnceMessageV2`, `editedMessage`, `documentWithCaptionMessage`…), and the text lives in a different place for every type (`conversation`, `extendedTextMessage.text`, `imageMessage.caption`…). `parse()` unwraps all of it once, in one place.
6
+
7
+ ```js
8
+ const { parse } = require("morpho-socket/message")
9
+
10
+ sock.ev.on("messages.upsert", ({ messages }) => {
11
+ const m = parse(messages[0])
12
+
13
+ m.type // "text" | "image" | "video" | "gif" | "videoNote" | "audio" | "voice" | "sticker" | "document" | ...
14
+ m.text // text or caption ("" if none)
15
+ m.mentions // mentioned JIDs — in ANY message type, not only text
16
+ m.media // { type, mimetype, seconds, size, ptt, animated, viewOnce, fileName, ... } or null
17
+ m.quoted // the replied-to message, parsed the same way (or null)
18
+ })
19
+ ```
20
+
21
+ `parse()` **never throws**: a null message, one that could not be decrypted, or an unknown type becomes `type: "empty"` / `"unknown"`. Parsing the same message object twice returns the same result.
22
+
23
+ ## The parsed message
24
+
25
+ | Group | Fields |
26
+ |---|---|
27
+ | where / who (raw JIDs — [`jid`](../jid) resolves LID → number) | `id` `chat` `sender` `senderAlt` `fromMe` `isGroup` `isStatus` `isNewsletter` `pushName` `timestamp` `key` |
28
+ | what | `type` `kind` (the original Baileys key, e.g. `"imageMessage"`) `text` `media` `selection` `reaction` `pollKey` |
29
+ | details | `mentions` `forwarded` `viewOnce` `edited` `editTarget` `revoked` `ephemeral` `expiration` `stub` `protocol` |
30
+ | raw | `context` (the raw `contextInfo`) · `node` (the raw content) |
31
+ | helper | `m.is("image", "video")` · `m.quoted` (lazy) |
32
+
33
+ **Types:** `text` `image` `video` `gif` `videoNote` `audio` `voice` `sticker` `document` `contact` `location` `poll` `pollVote` `reaction` `interactive` `reply` `invite` `event` `album` `other` · plus `system` `revoke` `empty` `unknown`.
34
+
35
+ - A tap on a button or list row has `type: "reply"` and `m.selection = { id, text }`.
36
+ - An edited message arrives as its **new** content with `edited: true`; a deleted one is `type: "revoke"`.
37
+ - `unknown` means a Baileys key Morpho does not know yet — check `m.kind`.
38
+
39
+ ## Finding media
40
+
41
+ ```js
42
+ const { findMedia } = require("morpho-socket/message")
43
+
44
+ findMedia(msg, { types: ["image", "video"], quoted: "prefer" }) // → media object (+ fromQuoted) or null
45
+ ```
46
+
47
+ `quoted`: `"prefer"` (default — the replied-to message's media wins, so replying to a sticker with a command works), `"only"`, or `"never"`. `families` also matches documents that are really images/video/audio; `accept(media)` is an extra filter.
48
+
49
+ ## Other exports
50
+
51
+ `unwrap(message)` · `textOf(msg)` · `toNumber(x)` (numbers, bigint, protobuf `Long` → number) · `WRAPPERS` · `MEDIA_TYPES`
52
+
53
+ Zero dependencies.