morpho-socket 0.0.0-stage → 0.3.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 +35 -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 +100 -0
  9. package/packages/context/group.js +269 -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,285 @@
1
+ // @morpho/jid — camada 3 do Morpho: IDENTIDADE.
2
+ //
3
+ // O WhatsApp hoje identifica a mesma pessoa de dois jeitos: o número de telefone (JID "5511...@s.whatsapp.net",
4
+ // "PN") e um identificador opaco (LID, "123...@lid") que NÃO é o telefone. Dependendo da versão do Baileys, do tipo
5
+ // de chat e de quem é a pessoa, a mensagem chega com um ou com o outro. Esta camada devolve sempre o mesmo:
6
+ // o número limpo, só dígitos, ou null quando de fato não dá pra descobrir (e diz de onde veio a resposta).
7
+ //
8
+ // const { criarResolvedor } = require("@morpho/jid")
9
+ // const identidade = criarResolvedor({ store }) // store é opcional; com ele o mapa LID→número sobrevive a reinício
10
+ // pool.on("connect", (_id, sock) => identidade.instalar(sock)) // aprende mapeamentos que o Baileys anuncia
11
+ // const quem = await identidade.remetente(msg, { sock })
12
+ // quem.numero // "5511968111161" | null
13
+ // quem.formatado // "+55 (11) 96811-1161"
14
+ // quem.ehBot // true se quem mandou foi o próprio número do bot
15
+ // quem.origem // "bot" | "mensagem" | "alt" | "mapa" | "baileys" | "grupo" | null
16
+ //
17
+ // Sem dependência do Baileys: o que for específico dele (sock.user, signalRepository, groupMetadata) é lido do
18
+ // `sock` que você passa, e cada fonte é opcional — se a versão do Baileys não tem, a próxima fonte é tentada.
19
+
20
+ const PN_DOMAINS = new Set(["s.whatsapp.net", "c.us"])
21
+ const LID_DOMAINS = new Set(["lid", "hosted.lid"])
22
+
23
+ // JID do Baileys: usuario[_agente][:dispositivo]@dominio
24
+ function parts(jid) {
25
+ if (typeof jid !== "string") return null
26
+ const at = jid.lastIndexOf("@")
27
+ if (at <= 0) return null
28
+ const [withAgent, device] = jid.slice(0, at).split(":")
29
+ const [user, agent] = withAgent.split("_")
30
+ return { user, agent, device, domain: jid.slice(at + 1) }
31
+ }
32
+
33
+ function kind(jid) {
34
+ const p = parts(jid)
35
+ if (!p) return null
36
+ if (PN_DOMAINS.has(p.domain)) return "pn"
37
+ if (LID_DOMAINS.has(p.domain)) return "lid"
38
+ if (p.domain === "g.us") return "group"
39
+ if (p.domain === "broadcast") return "broadcast"
40
+ if (p.domain === "newsletter") return "newsletter"
41
+ return "other"
42
+ }
43
+
44
+ // tira dispositivo e agente: "5511...:12@s.whatsapp.net" -> "5511...@s.whatsapp.net"
45
+ function normalize(jid) {
46
+ const p = parts(jid)
47
+ if (!p) return null
48
+ return `${p.user}@${p.domain === "c.us" ? "s.whatsapp.net" : p.domain}`
49
+ }
50
+
51
+ // só dígitos. Aceita JID de telefone ou uma string de número. LID, grupo etc. NÃO viram número (devolve null).
52
+ function digits(value) {
53
+ if (typeof value !== "string" || !value) return null
54
+ if (value.includes("@")) {
55
+ if (kind(value) !== "pn") return null
56
+ const u = parts(value).user
57
+ return /^\d{8,15}$/.test(u) ? u : null
58
+ }
59
+ const d = value.replace(/\D/g, "")
60
+ return d.length >= 8 && d.length <= 15 ? d : null
61
+ }
62
+
63
+ // "5511968111161" -> "+55 (11) 96811-1161" (Brasil); outros países: "+<dígitos>"
64
+ function formatNumber(d) {
65
+ if (!d) return null
66
+ const br = /^55(\d{2})(\d{8,9})$/.exec(d)
67
+ if (!br) return `+${d}`
68
+ const rest = br[2]
69
+ return `+55 (${br[1]}) ${rest.slice(0, rest.length - 4)}-${rest.slice(-4)}`
70
+ }
71
+
72
+ function createResolver({ store, warmUpIntervalMs = 120000 } = {}) {
73
+ const memory = new Map() // "123@lid" -> "5511..." (só dígitos)
74
+ const lidKey = lid => (kind(lid) === "lid" ? normalize(lid) : null)
75
+
76
+ // guarda o par LID↔número (memória + store, se o store souber guardar). Devolve true se aprendeu algo novo.
77
+ function learn(lid, pn) {
78
+ const k = lidKey(lid)
79
+ const d = digits(pn)
80
+ if (!k || !d || memory.get(k) === d) return false
81
+ memory.set(k, d)
82
+ try { store?.setLid?.(k, d) } catch { /* guardar é bônus: não pode derrubar a mensagem */ }
83
+ return true
84
+ }
85
+
86
+ async function remembered(k) {
87
+ if (memory.has(k)) return memory.get(k)
88
+ let d = null
89
+ try { d = await store?.getLid?.(k) } catch { /* idem */ }
90
+ if (d) memory.set(k, d)
91
+ return d || null
92
+ }
93
+
94
+ // o Baileys 7 já manda o "par" do JID na própria chave da mensagem (remoteJidAlt / participantAlt): aproveita sempre
95
+ function learnFrom(msg) {
96
+ const k = msg?.key || {}
97
+ for (const [a, b] of [[k.remoteJid, k.remoteJidAlt], [k.participant, k.participantAlt]]) {
98
+ if (kind(a) === "lid") learn(a, b)
99
+ else if (kind(b) === "lid") learn(b, a)
100
+ }
101
+ }
102
+
103
+ // lista de participantes de UM grupo: cada membro traz os dois ids (LID e telefone). Devolve quantos pares novos aprendeu.
104
+ function learnGroup(meta) {
105
+ let added = 0
106
+ for (const p of meta?.participants || []) {
107
+ const ids = [p.id, p.lid, p.phoneNumber, p.jid].filter(x => typeof x === "string")
108
+ const l = ids.find(x => kind(x) === "lid")
109
+ const n = ids.find(x => kind(x) === "pn")
110
+ if (l && n && learn(l, n)) added++
111
+ }
112
+ return added
113
+ }
114
+
115
+ // O mapa LID→número que vale pro grupo vale pro PRIVADO também: quem está num grupo com o bot, o bot já "conhece".
116
+ // Então, em vez de esperar a pessoa falar num grupo, lê TODOS os grupos do bot de uma vez (1 consulta) e aprende todo mundo.
117
+ // Com intervalo mínimo entre consultas, pra não pesar no WhatsApp (e pra um LID sem solução não disparar uma por mensagem).
118
+ let lastWarmUp = 0
119
+ let warming = null
120
+ function warmUp(sock, { force = false } = {}) {
121
+ if (typeof sock?.groupFetchAllParticipating !== "function") return Promise.resolve(0)
122
+ if (warming) return warming
123
+ if (!force && Date.now() - lastWarmUp < warmUpIntervalMs) return Promise.resolve(0)
124
+ lastWarmUp = Date.now()
125
+ warming = (async () => {
126
+ try {
127
+ let added = 0
128
+ for (const meta of Object.values((await sock.groupFetchAllParticipating()) || {})) added += learnGroup(meta)
129
+ return added
130
+ } catch { return 0 } finally { warming = null }
131
+ })()
132
+ return warming
133
+ }
134
+
135
+ function learnContact(c) {
136
+ if (!c) return
137
+ learn(c.lid, c.phoneNumber || c.pn)
138
+ if (kind(c.id) === "pn") learn(c.lid, c.id)
139
+ else if (kind(c.id) === "lid") learn(c.id, c.phoneNumber || c.pn)
140
+ }
141
+
142
+ // liga o resolvedor nos eventos do socket que anunciam mapeamentos. Chame a cada (re)conexão: o socket é novo.
143
+ function install(sock) {
144
+ const ev = sock?.ev
145
+ if (!ev?.on) return
146
+ ev.on("lid-mapping.update", u => learn(u?.lid, u?.pn))
147
+ const contacts = list => { for (const c of list || []) learnContact(c) }
148
+ ev.on("contacts.upsert", contacts)
149
+ ev.on("contacts.update", contacts)
150
+ ev.on("groups.upsert", list => { for (const m of list || []) learnGroup(m) })
151
+ ev.on("group-participants.update", u => { // participants vem como texto (Baileys 6) ou como objetos {id, phoneNumber} (7)
152
+ for (const p of u?.participants || []) if (p && typeof p === "object") learnGroup({ participants: [p] })
153
+ })
154
+ }
155
+
156
+ // LID -> número, tentando as fontes da mais barata pra mais cara.
157
+ async function resolveLid(lid, { sock, group } = {}) {
158
+ const k = lidKey(lid)
159
+ if (!k) return null
160
+
161
+ const stored = await remembered(k)
162
+ if (stored) return { number: stored, source: "map" }
163
+
164
+ const repo = sock?.signalRepository?.lidMapping // existe no Baileys 7
165
+ if (typeof repo?.getPNForLID === "function") {
166
+ try {
167
+ const pn = await repo.getPNForLID(k)
168
+ const d = digits(pn)
169
+ if (d) { learn(k, pn); return { number: d, source: "baileys" } }
170
+ } catch { /* próxima fonte */ }
171
+ }
172
+
173
+ // em grupo, a lista de participantes traz os dois ids de cada um: aprende o grupo inteiro de uma vez
174
+ if (kind(group) === "group" && typeof sock?.groupMetadata === "function") {
175
+ try {
176
+ learnGroup(await sock.groupMetadata(group))
177
+ const d = memory.get(k)
178
+ if (d) return { number: d, source: "group" }
179
+ } catch { /* sem metadata: tenta a próxima fonte */ }
180
+ }
181
+
182
+ // privado (ou grupo que não adiantou): pergunta por todos os grupos do bot
183
+ await warmUp(sock)
184
+ const d = memory.get(k)
185
+ if (d) return { number: d, source: "groups" }
186
+ return null
187
+ }
188
+
189
+ // Quem mandou esta mensagem? (em grupo: o participante; no privado: o dono do chat; fromMe: o próprio bot)
190
+ async function sender(msg, { sock } = {}) {
191
+ const key = msg?.key || {}
192
+ const group = kind(key.remoteJid) === "group" ? key.remoteJid : null
193
+ const myPn = sock?.user?.id
194
+ const myLid = sock?.user?.lid
195
+ const botNumber = digits(myPn)
196
+ learnFrom(msg)
197
+
198
+ const finish = o => {
199
+ const number = o.number || null
200
+ return {
201
+ group, isBot: false, jid: null, lid: null, source: null, ...o,
202
+ pn: number ? `${number}@s.whatsapp.net` : null,
203
+ number, formatted: formatNumber(number), resolved: !!number
204
+ }
205
+ }
206
+ const bot = () => finish({ isBot: true, jid: myPn || key.participant || key.remoteJid || null, lid: normalize(myLid) || null, number: botNumber, source: "bot" })
207
+
208
+ if (key.fromMe) return bot()
209
+
210
+ const raw = group ? key.participant : key.remoteJid
211
+ if (!raw) return finish({})
212
+ if ([myPn, myLid].some(x => x && normalize(x) === normalize(raw))) return bot()
213
+
214
+ const t = kind(raw)
215
+ if (t === "pn") return finish({ jid: raw, number: digits(raw), source: "message" })
216
+ if (t === "lid") {
217
+ const alt = group ? key.participantAlt : key.remoteJidAlt
218
+ let number = kind(alt) === "pn" ? digits(alt) : null
219
+ let source = number ? "alt" : null
220
+ if (!number) { const r = await resolveLid(raw, { sock, group }); if (r) ({ number, source } = r) }
221
+ return finish({ jid: raw, lid: normalize(raw), number, source })
222
+ }
223
+ return finish({ jid: raw })
224
+ }
225
+
226
+ return { sender, resolveLid, learn, learnFrom, learnGroup, warmUp, install }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------------------------
230
+ // createIdentity: ONE service for a multi-client bot. A resolver per client (the warm-up that reads all groups is per bot
231
+ // number), all sharing the same store (a person's LID is the same for every bot). Never throws: if resolving fails, the
232
+ // message must NOT be lost because of identity, so it falls back to what came in the message.
233
+ //
234
+ // const identity = createIdentity({ store })
235
+ // identity.install(sock, clientId) // on every (re)connection
236
+ // const who = await identity.resolveSender(msg, sock, clientId) // { number, formatted, pn, jid, lid, isBot, source, resolved, group }
237
+ // const r = await identity.resolveJid(jid, { sock, clientId, group }) // { number, source } | null
238
+ // ---------------------------------------------------------------------------------------------
239
+ function createIdentity({ store, warmUpIntervalMs } = {}) {
240
+ const resolvers = new Map()
241
+ const forClient = (clientId = "default") => {
242
+ let r = resolvers.get(clientId)
243
+ if (!r) { r = createResolver({ store, warmUpIntervalMs }); resolvers.set(clientId, r) }
244
+ return r
245
+ }
246
+
247
+ function fallback(msg, sock) {
248
+ const k = msg?.key || {}
249
+ const jid = k.fromMe ? sock?.user?.id : ((k.remoteJid || "").endsWith("@g.us") ? k.participant : k.remoteJid)
250
+ const number = digits(jid || "")
251
+ return {
252
+ number, formatted: null, pn: number ? `${number}@s.whatsapp.net` : null, jid: jid || null,
253
+ lid: kind(jid) === "lid" ? normalize(jid) : null, isBot: !!k.fromMe, source: null, resolved: !!number, group: null
254
+ }
255
+ }
256
+
257
+ async function resolveSender(msg, sock, clientId) {
258
+ try { return await forClient(clientId).sender(msg, { sock }) }
259
+ catch (e) { return fallback(msg, sock) }
260
+ }
261
+
262
+ async function resolveJid(jid, { sock, clientId, group } = {}) {
263
+ if (!jid) return null
264
+ const t = kind(jid)
265
+ if (t === "pn") { const number = digits(jid); return number ? { number, source: "message" } : null }
266
+ if (t !== "lid") return null
267
+ try { const r = await forClient(clientId).resolveLid(jid, { sock, group }); return r ? { number: r.number, source: r.source } : null }
268
+ catch { return null }
269
+ }
270
+
271
+ // learns from the groups ALREADY fetched (sock.groupFetchAllParticipating()): no extra query
272
+ function learnGroups(clientId, groups) {
273
+ let learned = 0
274
+ for (const meta of Object.values(groups || {})) learned += forClient(clientId).learnGroup(meta)
275
+ return learned
276
+ }
277
+
278
+ return {
279
+ forClient, resolveSender, resolveJid, learnGroups, fallback,
280
+ install: (sock, clientId) => forClient(clientId).install(sock),
281
+ warmUp: (sock, clientId, opts = { force: true }) => forClient(clientId).warmUp(sock, opts)
282
+ }
283
+ }
284
+
285
+ module.exports = { createIdentity, createResolver, parts, kind, normalize, digits, formatNumber }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@morpho/jid",
3
+ "version": "0.1.0",
4
+ "description": "Identidade no Morpho: descobre o número limpo (só dígitos) de quem mandou a mensagem, resolvendo @lid para número de telefone, e lembra o mapa LID→número.",
5
+ "main": "index.js",
6
+ "license": "MIT"
7
+ }
@@ -0,0 +1,45 @@
1
+ # morpho-socket/kv
2
+
3
+ Where your bot's **data** lives — economy, group settings, cooldowns — in a single SQLite file, with no dependency (`node:sqlite`, Node ≥ 22.5).
4
+
5
+ Instead of "one JSON file per thing plus a delayed write", every write is on disk when the call returns, and several writes can be **all-or-nothing**.
6
+
7
+ ```js
8
+ const { openKV, DELETE } = require("morpho-socket/kv")
9
+ const db = openKV("data/bot.sqlite") // ":memory:" in tests
10
+ const eco = db.collection("economy") // a named drawer: string keys → any JSON
11
+
12
+ eco.set("5511999", { coins: 10 })
13
+ eco.get("5511999") // { coins: 10 } (get(key, fallback) when it doesn't exist)
14
+ eco.update("5511999", u => ({ ...u, coins: u.coins + 5 }), { coins: 0 }) // atomic read-modify-write
15
+ eco.incr("counter") // number += 1
16
+ ```
17
+
18
+ ## Transfers without losing anything
19
+
20
+ ```js
21
+ db.transaction(() => { // if anything throws in here, NOTHING is written
22
+ eco.update(from, u => { if (u.coins < n) throw new Error("insufficient funds"); return { ...u, coins: u.coins - n } })
23
+ eco.update(to, u => ({ ...u, coins: u.coins + n }))
24
+ })
25
+ ```
26
+
27
+ The transaction function is **synchronous on purpose** (an `async` one is rejected): SQLite is a local file, calls take microseconds, and the absence of `await` is what guarantees nobody touches the data in the middle. Nested transactions become savepoints.
28
+
29
+ ## Everything else
30
+
31
+ | | |
32
+ |---|---|
33
+ | read | `get(k, fallback)` `has(k)` `keys(prefix?)` `entries(prefix?)` `values(prefix?)` `count(prefix?)` `toObject(prefix?)` |
34
+ | write | `set(k, v, { ttl })` `update(k, fn, initial)` `incr(k, n)` `delete(k)` `setMany(object \| pairs)` `clear(prefix?)` |
35
+ | expiry | `set(k, v, { ttl: ms })` — disappears by itself (a `/daily` cooldown, a cache). `update` keeps the original expiry; a plain `set` resets it |
36
+ | ranking | `top("coins", { limit: 10, prefix, order: "desc" })` → `[[key, value], …]` (accepts `"stats.wins"`; ignores entries without the field) |
37
+ | scope | `groups.sub(clientId, groupId)` → an isolated drawer: keys become `clientId/groupId/…`, so one client never sees another's data |
38
+ | database | `db.stats()` `db.collections()` `db.sweep()` `db.backup("copy.sqlite")` `db.close()` |
39
+
40
+ - `undefined` can't be stored (use `delete`, or `null`). In `update`, return the new value, or `DELETE` to remove the key.
41
+ - Two processes on the same file (bot + dashboard) see each other immediately (WAL mode, waits up to 5 s on a lock).
42
+ - `backup()` makes a consistent copy while the bot is running; it fails if the target file already exists.
43
+ - Importing old JSON files: `eco.setMany(JSON.parse(fs.readFileSync("economy.json", "utf8")))` (all-or-nothing).
44
+
45
+ `node:sqlite` prints an `ExperimentalWarning` on current Node versions; it is harmless.
@@ -0,0 +1,164 @@
1
+ // @morpho/kv — where the bot's DATA lives (economy, groups' settings, cooldowns...): one SQLite file, zero dependencies
2
+ // (node:sqlite, Node >= 22.5). Replaces "a JSON per thing + a 3-second debounce": every write is on disk when the call
3
+ // returns, and several writes can be ALL-or-NOTHING.
4
+ //
5
+ // const { openKV, DELETE } = require("@morpho/kv")
6
+ // const db = openKV("data/morvyn.sqlite") // ":memory:" for tests
7
+ // const eco = db.collection("economy") // a named drawer: keys are strings, values are anything JSON
8
+ //
9
+ // eco.set("5511999", { coins: 10 }); eco.get("5511999") // -> { coins: 10 } (get(key, fallback) for a default)
10
+ // eco.update("5511999", u => ({ ...u, coins: u.coins + 5 }), { coins: 0 }) // read-modify-write, atomic
11
+ //
12
+ // db.transaction(() => { // ALL-or-NOTHING (throw = everything is undone)
13
+ // eco.update(from, u => ({ ...u, coins: u.coins - 50 }))
14
+ // eco.update(to, u => ({ ...u, coins: u.coins + 50 }))
15
+ // })
16
+ //
17
+ // const grupo = db.collection("groups").sub(clientId, groupId) // scoped: keys become "clientId/groupId/..." (client isolation)
18
+ // cooldowns.set(`${user}:daily`, true, { ttl: 24 * 3600e3 }) // expires by itself (get/has/keys never see it afterwards)
19
+ // eco.top("coins", { limit: 10 }) // ranking by a field of the JSON value
20
+ //
21
+ // Sync on purpose: SQLite here is a local file, calls take microseconds, and "no await" is what makes a transaction safe.
22
+ const { DatabaseSync } = require("node:sqlite")
23
+
24
+ const DELETE = Symbol("morpho.kv.delete") // return it from update() to remove the key
25
+ const PATH = /^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*|\[\d+\])*$/ // field paths for top(): "coins", "stats.wins"
26
+
27
+ const keyOf = k => {
28
+ if (typeof k === "number" && Number.isFinite(k)) k = String(k)
29
+ if (typeof k !== "string" || !k) throw new TypeError("kv: key must be a non-empty string")
30
+ return k
31
+ }
32
+ const encode = v => {
33
+ if (v === undefined) throw new TypeError("kv: undefined can't be stored (use delete(), or null)")
34
+ const s = JSON.stringify(v)
35
+ if (s === undefined) throw new TypeError("kv: value is not JSON-serializable")
36
+ return s
37
+ }
38
+ // every key that starts with `prefix` is in [prefix, prefix + U+10FFFF) (BINARY collation compares UTF-8 bytes)
39
+ const range = prefix => [prefix, prefix + "\u{10FFFF}"]
40
+
41
+ function openKV(path = ":memory:", { now = Date.now, sweepEveryMs = 10 * 60_000, busyTimeoutMs = 5000 } = {}) {
42
+ const sql = new DatabaseSync(path)
43
+ sql.exec(`
44
+ PRAGMA journal_mode = WAL;
45
+ PRAGMA synchronous = NORMAL;
46
+ PRAGMA busy_timeout = ${Number(busyTimeoutMs) | 0};
47
+ CREATE TABLE IF NOT EXISTS kv (
48
+ col TEXT NOT NULL,
49
+ key TEXT NOT NULL,
50
+ value TEXT NOT NULL,
51
+ exp INTEGER, -- NULL = never expires; else epoch ms
52
+ PRIMARY KEY (col, key)
53
+ ) WITHOUT ROWID;
54
+ CREATE INDEX IF NOT EXISTS kv_exp ON kv (exp) WHERE exp IS NOT NULL;
55
+ `)
56
+
57
+ const cache = new Map()
58
+ const stmt = q => { let s = cache.get(q); if (!s) cache.set(q, s = sql.prepare(q)); return s }
59
+ const live = "(exp IS NULL OR exp > ?)"
60
+
61
+ // ----- transactions (nest = savepoints) -----
62
+ let depth = 0
63
+ function transaction(fn) {
64
+ if (typeof fn !== "function") throw new TypeError("kv.transaction: pass a function")
65
+ const name = `sp${depth}`
66
+ sql.exec(depth === 0 ? "BEGIN IMMEDIATE" : `SAVEPOINT ${name}`)
67
+ depth++
68
+ let result
69
+ try {
70
+ result = fn()
71
+ if (result && typeof result.then === "function") throw new TypeError("kv.transaction: the function must be synchronous (no await inside: SQLite is sync, and an open transaction can't wait)")
72
+ } catch (e) {
73
+ depth--
74
+ sql.exec(depth === 0 ? "ROLLBACK" : `ROLLBACK TO ${name}; RELEASE ${name}`)
75
+ throw e
76
+ }
77
+ depth--
78
+ sql.exec(depth === 0 ? "COMMIT" : `RELEASE ${name}`)
79
+ return result
80
+ }
81
+
82
+ function sweep() { return Number(stmt("DELETE FROM kv WHERE exp IS NOT NULL AND exp <= ?").run(now()).changes) }
83
+ const timer = sweepEveryMs ? setInterval(() => { try { sweep() } catch {} }, sweepEveryMs) : null
84
+ timer?.unref?.()
85
+
86
+ // a "drawer": one collection, optionally scoped by a key prefix
87
+ function makeCollection(col, scope = "") {
88
+ const K = k => scope + keyOf(k)
89
+ const unK = k => k.slice(scope.length)
90
+ const pre = (prefix = "") => range(scope + prefix)
91
+ const api = {
92
+ name: col, scope,
93
+
94
+ get(key, fallback) {
95
+ const r = stmt(`SELECT value FROM kv WHERE col = ? AND key = ? AND ${live}`).get(col, K(key), now())
96
+ return r ? JSON.parse(r.value) : fallback
97
+ },
98
+ has(key) { return !!stmt(`SELECT 1 x FROM kv WHERE col = ? AND key = ? AND ${live}`).get(col, K(key), now()) },
99
+ // opts: { ttl: ms } -> expires after that long (a plain set() clears any previous expiry)
100
+ set(key, value, { ttl } = {}) {
101
+ const exp = ttl === undefined || ttl === null ? null : now() + Number(ttl)
102
+ if (exp !== null && !Number.isFinite(exp)) throw new TypeError("kv.set: ttl must be a number (ms)")
103
+ stmt("INSERT INTO kv (col, key, value, exp) VALUES (?, ?, ?, ?) ON CONFLICT(col, key) DO UPDATE SET value = excluded.value, exp = excluded.exp")
104
+ .run(col, K(key), encode(value), exp)
105
+ return value
106
+ },
107
+ // read-modify-write in ONE transaction. fn(old) must return the new value (or DELETE). `initial` is `old` when the key doesn't exist.
108
+ // The expiry of an existing key is kept.
109
+ update(key, fn, initial) {
110
+ return transaction(() => {
111
+ const r = stmt(`SELECT value, exp FROM kv WHERE col = ? AND key = ? AND ${live}`).get(col, K(key), now())
112
+ const next = fn(r ? JSON.parse(r.value) : initial)
113
+ if (next === DELETE) { stmt("DELETE FROM kv WHERE col = ? AND key = ?").run(col, K(key)); return undefined }
114
+ if (next === undefined) throw new TypeError("kv.update: return the new value (or DELETE to remove the key)")
115
+ stmt("INSERT INTO kv (col, key, value, exp) VALUES (?, ?, ?, ?) ON CONFLICT(col, key) DO UPDATE SET value = excluded.value, exp = excluded.exp")
116
+ .run(col, K(key), encode(next), r?.exp ?? null)
117
+ return next
118
+ })
119
+ },
120
+ // number += n (starts at 0). For objects use update().
121
+ incr(key, n = 1) { return api.update(key, v => { if (typeof v !== "number") throw new TypeError(`kv.incr: "${key}" holds ${typeof v}, not a number`); return v + n }, 0) },
122
+ delete(key) { return stmt("DELETE FROM kv WHERE col = ? AND key = ?").run(col, K(key)).changes > 0 },
123
+
124
+ keys(prefix = "") { const [a, b] = pre(prefix); return stmt(`SELECT key FROM kv WHERE col = ? AND key >= ? AND key < ? AND ${live} ORDER BY key`).all(col, a, b, now()).map(r => unK(r.key)) },
125
+ entries(prefix = "") { const [a, b] = pre(prefix); return stmt(`SELECT key, value FROM kv WHERE col = ? AND key >= ? AND key < ? AND ${live} ORDER BY key`).all(col, a, b, now()).map(r => [unK(r.key), JSON.parse(r.value)]) },
126
+ values(prefix = "") { return api.entries(prefix).map(e => e[1]) },
127
+ count(prefix = "") { const [a, b] = pre(prefix); return Number(stmt(`SELECT COUNT(*) n FROM kv WHERE col = ? AND key >= ? AND key < ? AND ${live}`).get(col, a, b, now()).n) },
128
+ toObject(prefix = "") { return Object.fromEntries(api.entries(prefix)) },
129
+ // many at once (all-or-nothing). Accepts { key: value } or [[key, value]]. Handy to import an old JSON file.
130
+ setMany(data, opts) { const list = Array.isArray(data) ? data : Object.entries(data); return transaction(() => { for (const [k, v] of list) api.set(k, v, opts); return list.length }) },
131
+ clear(prefix = "") { const [a, b] = pre(prefix); return Number(stmt("DELETE FROM kv WHERE col = ? AND key >= ? AND key < ?").run(col, a, b).changes) },
132
+
133
+ // ranking by a field of the JSON value: top("coins", { limit: 10, prefix, order: "desc" }) -> [[key, value], ...]
134
+ top(field, { limit = 10, prefix = "", order = "desc" } = {}) {
135
+ if (!PATH.test(field)) throw new TypeError(`kv.top: invalid field "${field}" (use "coins" or "stats.wins")`)
136
+ const [a, b] = pre(prefix)
137
+ const dir = String(order).toLowerCase() === "asc" ? "ASC" : "DESC"
138
+ const jp = "$." + field
139
+ return stmt(`SELECT key, value FROM kv WHERE col = ? AND key >= ? AND key < ? AND ${live} AND json_type(value, ?) IN ('integer','real') ORDER BY json_extract(value, ?) ${dir}, key LIMIT ?`)
140
+ .all(col, a, b, now(), jp, jp, Math.max(1, limit | 0)).map(r => [unK(r.key), JSON.parse(r.value)])
141
+ },
142
+
143
+ // a narrower drawer: keys get the parts joined by "/" as a prefix (e.g. sub(clientId, groupId))
144
+ sub(...parts) {
145
+ const p = parts.map(x => { const s = keyOf(x); if (s.includes("/")) throw new TypeError(`kv.sub: "${s}" can't contain "/"`); return s }).join("/") + "/"
146
+ return makeCollection(col, scope + p)
147
+ }
148
+ }
149
+ return api
150
+ }
151
+
152
+ return {
153
+ collection(name) { if (typeof name !== "string" || !name || name.includes("\0")) throw new TypeError("kv.collection: name must be a non-empty string"); return makeCollection(name) },
154
+ transaction, sweep,
155
+ collections() { return stmt("SELECT DISTINCT col FROM kv ORDER BY col").all().map(r => r.col) },
156
+ stats() { const o = {}; for (const r of stmt(`SELECT col, COUNT(*) n FROM kv WHERE ${live} GROUP BY col`).all(now())) o[r.col] = Number(r.n); return o },
157
+ // consistent copy of the whole database into ONE file (safe while the bot is running). Fails if the file already exists.
158
+ backup(file) { sql.prepare("VACUUM INTO ?").run(String(file)); return file },
159
+ close() { if (timer) clearInterval(timer); cache.clear(); sql.close() },
160
+ get raw() { return sql }
161
+ }
162
+ }
163
+
164
+ module.exports = { openKV, DELETE }
@@ -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`.