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.
- package/README.md +155 -2
- package/docs/building-a-bot.md +402 -0
- package/package.json +35 -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 +100 -0
- package/packages/context/group.js +269 -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,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`.
|