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.
- package/README.md +155 -2
- package/docs/building-a-bot.md +402 -0
- package/package.json +25 -4
- package/packages/commands/README.md +78 -0
- package/packages/commands/antiflood.js +39 -0
- package/packages/commands/index.js +116 -0
- package/packages/commands/package.json +1 -0
- package/packages/context/README.md +97 -0
- package/packages/context/group.js +245 -0
- package/packages/context/index.js +132 -0
- package/packages/context/package.json +1 -0
- package/packages/context/participants.js +12 -0
- package/packages/context/send.js +127 -0
- package/packages/core/README.md +50 -0
- package/packages/core/baileys.js +25 -0
- package/packages/core/codec.js +17 -0
- package/packages/core/index.js +86 -0
- package/packages/core/package.json +7 -0
- package/packages/core/store.js +11 -0
- package/packages/groups/README.md +42 -0
- package/packages/groups/index.js +205 -0
- package/packages/groups/package.json +7 -0
- package/packages/interactive/README.md +41 -0
- package/packages/interactive/index.js +92 -0
- package/packages/interactive/package.json +1 -0
- package/packages/jid/README.md +51 -0
- package/packages/jid/index.js +285 -0
- package/packages/jid/package.json +7 -0
- package/packages/kv/README.md +45 -0
- package/packages/kv/index.js +164 -0
- package/packages/kv/package.json +8 -0
- package/packages/media/README.md +70 -0
- package/packages/media/cache.js +116 -0
- package/packages/media/erros.js +45 -0
- package/packages/media/find.js +51 -0
- package/packages/media/index.js +261 -0
- package/packages/media/package.json +8 -0
- package/packages/message/README.md +53 -0
- package/packages/message/index.js +317 -0
- package/packages/message/package.json +7 -0
- package/packages/pool/README.md +75 -0
- package/packages/pool/index.js +369 -0
- package/packages/pool/package.json +8 -0
- package/packages/presence/README.md +40 -0
- package/packages/presence/index.js +80 -0
- package/packages/presence/package.json +1 -0
- package/packages/reply/README.md +36 -0
- package/packages/reply/index.js +126 -0
- package/packages/reply/package.json +7 -0
- package/packages/reply/voice.js +32 -0
- package/packages/sent/README.md +27 -0
- package/packages/sent/index.js +65 -0
- package/packages/sent/package.json +1 -0
- package/packages/store-file/README.md +28 -0
- package/packages/store-file/index.js +63 -0
- package/packages/store-file/package.json +8 -0
- package/packages/store-sqlite/README.md +25 -0
- package/packages/store-sqlite/index.js +98 -0
- 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.
|