morpho-socket 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +155 -2
  2. package/docs/building-a-bot.md +402 -0
  3. package/package.json +25 -4
  4. package/packages/commands/README.md +78 -0
  5. package/packages/commands/antiflood.js +39 -0
  6. package/packages/commands/index.js +116 -0
  7. package/packages/commands/package.json +1 -0
  8. package/packages/context/README.md +97 -0
  9. package/packages/context/group.js +245 -0
  10. package/packages/context/index.js +132 -0
  11. package/packages/context/package.json +1 -0
  12. package/packages/context/participants.js +12 -0
  13. package/packages/context/send.js +127 -0
  14. package/packages/core/README.md +50 -0
  15. package/packages/core/baileys.js +25 -0
  16. package/packages/core/codec.js +17 -0
  17. package/packages/core/index.js +86 -0
  18. package/packages/core/package.json +7 -0
  19. package/packages/core/store.js +11 -0
  20. package/packages/groups/README.md +42 -0
  21. package/packages/groups/index.js +205 -0
  22. package/packages/groups/package.json +7 -0
  23. package/packages/interactive/README.md +41 -0
  24. package/packages/interactive/index.js +92 -0
  25. package/packages/interactive/package.json +1 -0
  26. package/packages/jid/README.md +51 -0
  27. package/packages/jid/index.js +285 -0
  28. package/packages/jid/package.json +7 -0
  29. package/packages/kv/README.md +45 -0
  30. package/packages/kv/index.js +164 -0
  31. package/packages/kv/package.json +8 -0
  32. package/packages/media/README.md +70 -0
  33. package/packages/media/cache.js +116 -0
  34. package/packages/media/erros.js +45 -0
  35. package/packages/media/find.js +51 -0
  36. package/packages/media/index.js +261 -0
  37. package/packages/media/package.json +8 -0
  38. package/packages/message/README.md +53 -0
  39. package/packages/message/index.js +317 -0
  40. package/packages/message/package.json +7 -0
  41. package/packages/pool/README.md +75 -0
  42. package/packages/pool/index.js +369 -0
  43. package/packages/pool/package.json +8 -0
  44. package/packages/presence/README.md +40 -0
  45. package/packages/presence/index.js +80 -0
  46. package/packages/presence/package.json +1 -0
  47. package/packages/reply/README.md +36 -0
  48. package/packages/reply/index.js +126 -0
  49. package/packages/reply/package.json +7 -0
  50. package/packages/reply/voice.js +32 -0
  51. package/packages/sent/README.md +27 -0
  52. package/packages/sent/index.js +65 -0
  53. package/packages/sent/package.json +1 -0
  54. package/packages/store-file/README.md +28 -0
  55. package/packages/store-file/index.js +63 -0
  56. package/packages/store-file/package.json +8 -0
  57. package/packages/store-sqlite/README.md +25 -0
  58. package/packages/store-sqlite/index.js +98 -0
  59. package/packages/store-sqlite/package.json +9 -0
@@ -0,0 +1,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 }