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,92 @@
|
|
|
1
|
+
// @morpho/interactive
|
|
2
|
+
//
|
|
3
|
+
// "Native" interactive messages: image + text + buttons and the selection list (the "MENU CATEGORIES" that opens to pick
|
|
4
|
+
// from, like WhatsApp Business). Baileys has no ready-made sendMessage for this, so the message is hand-built
|
|
5
|
+
// (interactiveMessage + nativeFlowMessage) and sent with relayMessage plus the "biz" nodes WhatsApp needs to draw buttons.
|
|
6
|
+
//
|
|
7
|
+
// const { createInteractive } = require("@morpho/interactive")
|
|
8
|
+
// const ui = createInteractive({ mark: "mv:" }) // every id this bot sends starts with the mark
|
|
9
|
+
// await ui.send(sock, jid, { body: "Pick one", buttons: [ui.button.reply("Menu", "menu"), ui.button.link("Site", url)], msg })
|
|
10
|
+
// ui.parseSelection(msg, { sock }) // -> "menu" | null (id without the mark; null if not ours)
|
|
11
|
+
const { parse } = require("../message")
|
|
12
|
+
|
|
13
|
+
const stripDevice = j => (j ? j.replace(/:\d+(?=@)/, "") : null)
|
|
14
|
+
const userPart = j => String(j).split("@")[0].split(":")[0]
|
|
15
|
+
const IMAGE_TTL_MS = 6 * 3600e3
|
|
16
|
+
|
|
17
|
+
function createInteractive({ mark = "mp:" } = {}) {
|
|
18
|
+
const button = {
|
|
19
|
+
// opens the selection list. sections = [{ title, rows: [row(...)] }]
|
|
20
|
+
list: (title, sections) => ({ name: "single_select", buttonParamsJson: JSON.stringify({ title, sections }) }),
|
|
21
|
+
// quick reply: when tapped the bot "receives" the id (e.g. "/menuadm") as if typed
|
|
22
|
+
reply: (text, id) => ({ name: "quick_reply", buttonParamsJson: JSON.stringify({ display_text: text, id: mark + id }) }),
|
|
23
|
+
link: (text, url) => ({ name: "cta_url", buttonParamsJson: JSON.stringify({ display_text: text, url, merchant_url: url }) })
|
|
24
|
+
}
|
|
25
|
+
const section = (title, rows) => ({ title, highlight_label: "", rows })
|
|
26
|
+
const row = (title, description, id) => ({ header: "", title, description: description || "", id: mark + id })
|
|
27
|
+
|
|
28
|
+
// quotes the message of whoever asked (the "!menu" bubble above the menu)
|
|
29
|
+
function quoteOf(sock, msg) {
|
|
30
|
+
if (!msg?.key?.id || !msg.message) return null
|
|
31
|
+
const m = msg.message
|
|
32
|
+
let quotedMessage = null
|
|
33
|
+
if (m.conversation) quotedMessage = { conversation: m.conversation }
|
|
34
|
+
else if (m.extendedTextMessage) quotedMessage = { extendedTextMessage: { text: m.extendedTextMessage.text, contextInfo: { mentionedJid: m.extendedTextMessage.contextInfo?.mentionedJid || [] } } }
|
|
35
|
+
else if (m.interactiveResponseMessage) quotedMessage = { conversation: m.interactiveResponseMessage.body?.text || "" } // tap on a list/button
|
|
36
|
+
else return null
|
|
37
|
+
const participant = msg.key.fromMe ? stripDevice(sock.user?.lid || sock.user?.id) : (msg.key.participant || msg.key.remoteJid)
|
|
38
|
+
return { stanzaId: msg.key.id, participant, quotedMessage }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// extra nodes: without them the buttons don't show. In private chats WhatsApp also wants the "bot" node.
|
|
42
|
+
function extraNodes(jid) {
|
|
43
|
+
const nodes = [{ tag: "biz", attrs: {}, content: [{ tag: "interactive", attrs: { type: "native_flow", v: "1" }, content: [{ tag: "native_flow", attrs: { v: "9", name: "mixed" } }] }] }]
|
|
44
|
+
if (!String(jid).endsWith("@g.us")) nodes.push({ tag: "bot", attrs: { biz_bot: "1" } })
|
|
45
|
+
return nodes
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The menu image is the same every time: upload once per socket and keep it for 6h (it was one upload per /menu).
|
|
49
|
+
const uploaded = new WeakMap() // sock -> Map(imageId -> { media, at })
|
|
50
|
+
async function prepareImage(sock, image, imageId, prepareWAMessageMedia) {
|
|
51
|
+
const store = imageId ? (uploaded.get(sock) || uploaded.set(sock, new Map()).get(sock)) : null
|
|
52
|
+
const hit = store?.get(imageId)
|
|
53
|
+
if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return hit.media
|
|
54
|
+
const media = await prepareWAMessageMedia({ image }, { upload: sock.waUploadToServer })
|
|
55
|
+
store?.set(imageId, { media, at: Date.now() })
|
|
56
|
+
return media
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// image (Buffer) is optional; mentions = JIDs cited as @number in the body. `baileys` only exists for tests (injection).
|
|
60
|
+
async function send(sock, jid, { image, imageId, body, footer, buttons, msg, mentions }, baileys) {
|
|
61
|
+
const { generateWAMessageFromContent, prepareWAMessageMedia } = baileys || require("../core/baileys").loadBaileys()
|
|
62
|
+
let header = { title: "", subtitle: "", hasMediaAttachment: false }
|
|
63
|
+
if (image) header = { title: "", subtitle: "", hasMediaAttachment: true, ...(await prepareImage(sock, image, imageId, prepareWAMessageMedia)) }
|
|
64
|
+
|
|
65
|
+
const interactiveMessage = { header, body: { text: body }, footer: { text: footer || "" }, nativeFlowMessage: { buttons, messageParamsJson: "" } }
|
|
66
|
+
const quote = quoteOf(sock, msg)
|
|
67
|
+
if (quote || mentions?.length) interactiveMessage.contextInfo = { ...(quote || {}), ...(mentions?.length ? { mentionedJid: mentions } : {}) }
|
|
68
|
+
|
|
69
|
+
const waMsg = generateWAMessageFromContent(jid, {
|
|
70
|
+
viewOnceMessage: { message: { messageContextInfo: { deviceListMetadata: {}, deviceListMetadataVersion: 2 }, interactiveMessage } }
|
|
71
|
+
}, { userJid: sock.user?.id })
|
|
72
|
+
|
|
73
|
+
await sock.relayMessage(jid, waMsg.message, { messageId: waMsg.key.id, additionalNodes: extraNodes(jid) })
|
|
74
|
+
return waMsg
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A tap on a button / list row: returns the id WITHOUT the mark (e.g. "menu games"), or null.
|
|
78
|
+
// Only ids with this bot's mark count, and a reply quoting ANOTHER number's message (another bot in the group) is ignored.
|
|
79
|
+
function parseSelection(msg, { sock } = {}) {
|
|
80
|
+
const m = parse(msg)
|
|
81
|
+
const id = m.selection?.id
|
|
82
|
+
if (typeof id !== "string" || !id.startsWith(mark)) return null
|
|
83
|
+
const author = m.context?.participant
|
|
84
|
+
const me = [sock?.user?.id, sock?.user?.lid].filter(Boolean).map(userPart)
|
|
85
|
+
if (author && me.length && !me.includes(userPart(author))) return null
|
|
86
|
+
return id.slice(mark.length)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { mark, button, section, row, send, quoteOf, extraNodes, parseSelection }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { createInteractive }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/interactive", "version": "0.1.0", "description": "Native WhatsApp interactive messages (image + text + buttons, selection lists) and parsing of the taps. Baileys has no ready-made sendMessage for this: the message is built by hand and relayed with the 'biz' nodes WhatsApp requires.", "main": "index.js", "dependencies": { "@morpho/message": "file:../message" }, "license": "MIT" }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# morpho-socket/jid
|
|
2
|
+
|
|
3
|
+
**Identity.** Find out who really sent a message.
|
|
4
|
+
|
|
5
|
+
WhatsApp now identifies the same person in two ways: the phone-number JID (`5511…@s.whatsapp.net`) and an opaque **LID** (`123…@lid`) that is *not* the phone number. Depending on the Baileys version, the chat type and the person, a message arrives with one or the other. This module always gives you the same thing — the clean number (digits only), or `null` when it truly cannot be found — and tells you where the answer came from.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { createIdentity } = require("morpho-socket/jid")
|
|
9
|
+
const identity = createIdentity({ store }) // store is optional; with it the LID map survives restarts
|
|
10
|
+
|
|
11
|
+
identity.install(sock, clientId) // on every (re)connection: learns mappings from socket events
|
|
12
|
+
|
|
13
|
+
const who = await identity.resolveSender(msg, sock, clientId)
|
|
14
|
+
who.number // "5511968111161" | null
|
|
15
|
+
who.formatted // "+55 (11) 96811-1161"
|
|
16
|
+
who.pn // "5511968111161@s.whatsapp.net" | null
|
|
17
|
+
who.lid // "123…@lid" | null
|
|
18
|
+
who.isBot // true when the sender is the bot's own number
|
|
19
|
+
who.source // "bot" | "message" | "alt" | "map" | "baileys" | "group" | "groups" | null
|
|
20
|
+
who.resolved // true when a number was found
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How a LID is resolved
|
|
24
|
+
|
|
25
|
+
From the cheapest source to the most expensive:
|
|
26
|
+
|
|
27
|
+
1. the pair Baileys 7 already sends in the message key (`participantAlt` / `remoteJidAlt`);
|
|
28
|
+
2. the **remembered map** (memory, plus your store when it implements `getLid`/`setLid`);
|
|
29
|
+
3. Baileys' own `signalRepository.lidMapping`, when available;
|
|
30
|
+
4. the group's participant list (each member carries both ids — the whole group is learned at once);
|
|
31
|
+
5. **warm-up**: one query for *all* the bot's groups. The map that holds in a group holds in private chats too, so anyone sharing a group with the bot gets resolved. Throttled (`warmUpIntervalMs`, 2 min) so an unresolvable LID cannot trigger one query per message.
|
|
32
|
+
|
|
33
|
+
Identity **never throws**. If resolving fails, `resolveSender` falls back to what arrived in the message — a message is never lost because of a lookup.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
`createIdentity({ store, warmUpIntervalMs })` — one service for a multi-client bot (one resolver per client, one shared store):
|
|
38
|
+
|
|
39
|
+
| | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `install(sock, clientId)` | start learning from `lid-mapping.update`, contacts and group events |
|
|
42
|
+
| `resolveSender(msg, sock, clientId)` | → the object above |
|
|
43
|
+
| `resolveJid(jid, { sock, clientId, group })` | → `{ number, source } \| null` |
|
|
44
|
+
| `learnGroups(clientId, groups)` | learn from groups you already fetched (no extra query) |
|
|
45
|
+
| `warmUp(sock, clientId, opts)` · `forClient(clientId)` · `fallback(msg, sock)` | lower level |
|
|
46
|
+
|
|
47
|
+
`createResolver({ store, warmUpIntervalMs })` is the single-number version (`sender`, `resolveLid`, `learn`, `learnFrom`, `learnGroup`, `warmUp`, `install`).
|
|
48
|
+
|
|
49
|
+
Utilities: `parts(jid)` · `kind(jid)` (`"pn" \| "lid" \| "group" \| "broadcast" \| "newsletter" \| "other"`) · `normalize(jid)` (drops device/agent) · `digits(value)` · `formatNumber(digits)` (Brazilian numbers get the local format, others `+<digits>`).
|
|
50
|
+
|
|
51
|
+
No dependency on Baileys: everything specific to it is read from the `sock` you pass, and each source is optional.
|
|
@@ -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 }
|