morpho-socket 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +155 -2
  2. package/docs/building-a-bot.md +402 -0
  3. package/package.json +35 -4
  4. package/packages/commands/README.md +78 -0
  5. package/packages/commands/antiflood.js +39 -0
  6. package/packages/commands/index.js +116 -0
  7. package/packages/commands/package.json +1 -0
  8. package/packages/context/README.md +100 -0
  9. package/packages/context/group.js +269 -0
  10. package/packages/context/index.js +132 -0
  11. package/packages/context/package.json +1 -0
  12. package/packages/context/participants.js +12 -0
  13. package/packages/context/send.js +127 -0
  14. package/packages/core/README.md +50 -0
  15. package/packages/core/baileys.js +25 -0
  16. package/packages/core/codec.js +17 -0
  17. package/packages/core/index.js +86 -0
  18. package/packages/core/package.json +7 -0
  19. package/packages/core/store.js +11 -0
  20. package/packages/groups/README.md +42 -0
  21. package/packages/groups/index.js +205 -0
  22. package/packages/groups/package.json +7 -0
  23. package/packages/interactive/README.md +41 -0
  24. package/packages/interactive/index.js +92 -0
  25. package/packages/interactive/package.json +1 -0
  26. package/packages/jid/README.md +51 -0
  27. package/packages/jid/index.js +285 -0
  28. package/packages/jid/package.json +7 -0
  29. package/packages/kv/README.md +45 -0
  30. package/packages/kv/index.js +164 -0
  31. package/packages/kv/package.json +8 -0
  32. package/packages/media/README.md +70 -0
  33. package/packages/media/cache.js +116 -0
  34. package/packages/media/erros.js +45 -0
  35. package/packages/media/find.js +51 -0
  36. package/packages/media/index.js +261 -0
  37. package/packages/media/package.json +8 -0
  38. package/packages/message/README.md +53 -0
  39. package/packages/message/index.js +317 -0
  40. package/packages/message/package.json +7 -0
  41. package/packages/pool/README.md +75 -0
  42. package/packages/pool/index.js +369 -0
  43. package/packages/pool/package.json +8 -0
  44. package/packages/presence/README.md +40 -0
  45. package/packages/presence/index.js +80 -0
  46. package/packages/presence/package.json +1 -0
  47. package/packages/reply/README.md +36 -0
  48. package/packages/reply/index.js +126 -0
  49. package/packages/reply/package.json +7 -0
  50. package/packages/reply/voice.js +32 -0
  51. package/packages/sent/README.md +27 -0
  52. package/packages/sent/index.js +65 -0
  53. package/packages/sent/package.json +1 -0
  54. package/packages/store-file/README.md +28 -0
  55. package/packages/store-file/index.js +63 -0
  56. package/packages/store-file/package.json +8 -0
  57. package/packages/store-sqlite/README.md +25 -0
  58. package/packages/store-sqlite/index.js +98 -0
  59. package/packages/store-sqlite/package.json +9 -0
@@ -0,0 +1,126 @@
1
+ // @morpho/reply
2
+ //
3
+ // Problem: when Baileys quotes a message that MENTIONS someone, it drops the original contextInfo (the mentions), so the
4
+ // quoted bubble shows a raw @lid/number instead of the person's name. The fix is to build the quote by hand, carrying the
5
+ // original mentions along. Extracted from Morvyn (utils/citar.js + utils/global.js), in production.
6
+ //
7
+ // Also the small "send something as a reply" helpers every bot rewrites: gif, photo, sticker, voice note, reaction, poll.
8
+ //
9
+ // const reply = require("@morpho/reply")
10
+ // await reply.reply(sock, jid, msg, "hello") // quotes msg, keeps its mentions
11
+ // await reply.replyGif(sock, jid, msg, videoBuffer, { caption, mentions })
12
+ // await reply.sendVoice(sock, jid, mp3Buffer, { quoted: msg }) // converts to ogg/opus (needs ffmpeg)
13
+ // await reply.sendSticker(sock, jid, webpBuffer, { quoted: msg }) // sticker to any chat (quotes only if asked)
14
+ const { toOggOpus } = require("./voice")
15
+
16
+ const stripDevice = j => (j ? j.replace(/:\d+(?=@)/, "") : null)
17
+
18
+ const mentionsOf = msg => msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid || []
19
+ const numberOf = j => String(j).split("@")[0].split(":")[0]
20
+
21
+ // Builds the contextInfo for sock.sendMessage, carrying the quoted message's mentions.
22
+ // Returns null when the message is not an extended text (then Baileys' normal quote is fine).
23
+ function buildQuoteContext(sock, msg, mentions = []) {
24
+ const inner = msg?.message?.extendedTextMessage
25
+ if (!inner || !msg?.key) return null
26
+ const key = msg.key
27
+ const participant = key.fromMe
28
+ ? stripDevice(sock.user?.lid || sock.user?.id)
29
+ : (key.participant || key.remoteJid)
30
+ return {
31
+ mentionedJid: mentions,
32
+ stanzaId: key.id,
33
+ participant,
34
+ quotedMessage: {
35
+ extendedTextMessage: {
36
+ text: inner.text,
37
+ contextInfo: { mentionedJid: inner.contextInfo?.mentionedJid || [] }
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ // Sends ANY content (text, image, ...) quoting `msg`. The manual quote (which keeps the original's mentions) is only used
44
+ // when the original message mentions someone, or with always=true; otherwise it is Baileys' normal quoted.
45
+ function sendQuoted(sock, jid, msg, content, { always } = {}) {
46
+ const ci = (always || mentionsOf(msg).length) ? buildQuoteContext(sock, msg, content.mentions || []) : null
47
+ if (!ci) return sock.sendMessage(jid, content, { quoted: msg })
48
+ return sock.sendMessage(jid, { ...content, contextInfo: ci })
49
+ }
50
+
51
+ // Text reply quoting `msg`, keeping its mentions (see sendQuoted).
52
+ function replyWithMentions(sock, jid, msg, text, mentions = [], extra = {}) {
53
+ const { always, ...rest } = extra
54
+ return sendQuoted(sock, jid, msg, { ...rest, text, mentions }, { always })
55
+ }
56
+
57
+ // Replies quoting `msg`; if the reply text repeats literally the "@number" of someone the original message mentioned,
58
+ // that person is really mentioned (WhatsApp swaps the number for the name).
59
+ // E.g. someone sends "/chance da @ana virar admin" and the bot answers "...@ana tem 42%..." — without this, the raw
60
+ // number would show in the text; with it, it becomes her name, clickable.
61
+ function reply(sock, jid, msg, text, extra = {}) {
62
+ if (!msg) return
63
+ const original = mentionsOf(msg)
64
+
65
+ if (original.length && typeof text === "string" && jid === msg.key?.remoteJid) {
66
+ const cited = original.filter(j => {
67
+ const n = numberOf(j).replace(/\D/g, "")
68
+ return n && new RegExp("@" + n + "(?!\\d)").test(text)
69
+ })
70
+ if (cited.length) {
71
+ const { mentions: own = [], ...rest } = extra
72
+ return replyWithMentions(sock, jid, msg, text, [...new Set([...own, ...cited])], rest)
73
+ }
74
+ }
75
+
76
+ return sock.sendMessage(jid, { text, ...extra }, { quoted: msg })
77
+ }
78
+
79
+ // Media quoting the original message (keeps its mentions). If sending fails (corrupt file, media too large...), falls
80
+ // back to a plain text reply instead of staying silent.
81
+ async function replyMedia(sock, jid, msg, content, { text, mentions = [], onError } = {}) {
82
+ try {
83
+ const ci = buildQuoteContext(sock, msg, mentions)
84
+ if (!ci) return await sock.sendMessage(jid, content, { quoted: msg })
85
+ return await sock.sendMessage(jid, { ...content, contextInfo: ci })
86
+ } catch (e) {
87
+ if (onError) onError(e)
88
+ return replyWithMentions(sock, jid, msg, text ?? "", mentions)
89
+ }
90
+ }
91
+
92
+ // GIF = a video with gifPlayback. `video` is a Buffer.
93
+ const replyGif = (sock, jid, msg, video, { caption = "", mentions = [], onError } = {}) =>
94
+ replyMedia(sock, jid, msg, { video, gifPlayback: true, caption, mentions }, { text: caption, mentions, onError })
95
+
96
+ const replyImage = (sock, jid, msg, image, { caption = "", mentions = [], onError } = {}) =>
97
+ replyMedia(sock, jid, msg, { image, caption, mentions }, { text: caption, mentions, onError })
98
+
99
+ const replySticker = (sock, jid, msg, sticker) => sock.sendMessage(jid, { sticker }, { quoted: msg })
100
+
101
+ // Sticker (WebP Buffer) to ANY chat. Quotes only when opts.quoted is given (replySticker always quotes).
102
+ // opts: { quoted, ...extra } extra goes into the message content (e.g. { contextInfo })
103
+ function sendSticker(sock, jid, sticker, { quoted, ...extra } = {}) {
104
+ if (!Buffer.isBuffer(sticker) || !sticker.length) throw new Error("sendSticker: sticker must be a non-empty Buffer")
105
+ return sock.sendMessage(jid, { sticker, ...extra }, quoted ? { quoted } : undefined)
106
+ }
107
+
108
+ // Voice note (PTT). WhatsApp only plays PTT as ogg/opus, so any other audio is converted first (needs ffmpeg).
109
+ // opts: { quoted, convert (default: toOggOpus; pass false when the buffer is already ogg/opus), ...extra }
110
+ async function sendVoice(sock, jid, audio, { quoted, convert = toOggOpus, ...extra } = {}) {
111
+ if (!Buffer.isBuffer(audio)) throw new Error("sendVoice: audio must be a Buffer")
112
+ const ogg = convert ? await convert(audio) : audio
113
+ return sock.sendMessage(jid, { audio: ogg, mimetype: "audio/ogg; codecs=opus", ptt: true, ...extra }, quoted ? { quoted } : undefined)
114
+ }
115
+
116
+ const react = (sock, msg, emoji) => sock.sendMessage(msg.key.remoteJid, { react: { text: emoji, key: msg.key } })
117
+
118
+ function sendPoll(sock, jid, question, options = [], { selectableCount = 1 } = {}) {
119
+ if (!options.length) return
120
+ return sock.sendMessage(jid, { poll: { name: question, values: options, selectableCount } })
121
+ }
122
+
123
+ module.exports = {
124
+ buildQuoteContext, sendQuoted, reply, replyWithMentions, replyMedia, replyGif, replyImage, replySticker,
125
+ sendSticker, sendVoice, react, sendPoll, toOggOpus, mentionsOf, stripDevice
126
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@morpho/reply",
3
+ "version": "0.2.0",
4
+ "description": "Reply on WhatsApp quoting the original message WITHOUT losing its mentions (Baileys drops the quoted contextInfo, which shows a raw @lid/number in the bubble), plus gif/photo/sticker/voice/reaction/poll helpers. Extracted from Morvyn in production.",
5
+ "main": "index.js",
6
+ "license": "MIT"
7
+ }
@@ -0,0 +1,32 @@
1
+ // Audio -> ogg/opus (the only format WhatsApp plays as a voice note). Needs the ffmpeg binary.
2
+ const fs = require("fs")
3
+ const os = require("os")
4
+ const path = require("path")
5
+ const crypto = require("crypto")
6
+ const { spawn } = require("child_process")
7
+
8
+ // opts: { ffmpeg: "ffmpeg", timeoutMs: 60000 }
9
+ async function toOggOpus(buffer, { ffmpeg = "ffmpeg", timeoutMs = 60_000 } = {}) {
10
+ if (!Buffer.isBuffer(buffer)) throw new Error("toOggOpus: buffer must be a Buffer")
11
+
12
+ // unique names (two audios at the same time don't step on each other) and files always removed at the end
13
+ const id = crypto.randomBytes(6).toString("hex")
14
+ const input = path.join(os.tmpdir(), `morpho_in_${id}`)
15
+ const output = path.join(os.tmpdir(), `morpho_out_${id}.ogg`)
16
+
17
+ try {
18
+ fs.writeFileSync(input, buffer)
19
+ // stdio ignored: with stderr on "pipe" and nobody reading it, ffmpeg can hang
20
+ await new Promise((resolve, reject) => {
21
+ const ff = spawn(ffmpeg, ["-i", input, "-c:a", "libopus", "-ac", "1", "-b:a", "64k", "-vbr", "on", "-y", output], { stdio: "ignore" })
22
+ const timer = setTimeout(() => { ff.kill("SIGKILL"); reject(new Error("ffmpeg took too long")) }, timeoutMs)
23
+ ff.on("close", code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code}`)) })
24
+ ff.on("error", e => { clearTimeout(timer); reject(e) })
25
+ })
26
+ return fs.readFileSync(output)
27
+ } finally {
28
+ for (const f of [input, output]) { try { fs.unlinkSync(f) } catch { /* already gone */ } }
29
+ }
30
+ }
31
+
32
+ module.exports = { toOggOpus }
@@ -0,0 +1,27 @@
1
+ # morpho-socket/sent
2
+
3
+ A short memory of **the messages the bot sent** — what Baileys' `getMessage` asks for.
4
+
5
+ Baileys calls `getMessage` in two situations: re-sending a message the recipient couldn't decrypt, and opening poll votes. With no store, both fail silently.
6
+
7
+ ```js
8
+ const { createSentStore } = require("morpho-socket/sent")
9
+ const sent = createSentStore({ max: 1500, ttlMs: 2 * 3600e3 })
10
+
11
+ const sock = makeWASocket({ /* … */, getMessage: key => sent.get(key) })
12
+
13
+ sent.install(sock, {
14
+ onSent: (jid, content, result) => { /* a real message left (not edit/delete/react) */ },
15
+ onIncoming: msg => { /* every message received from others — e.g. to register polls */ },
16
+ })
17
+ ```
18
+
19
+ `install(sock)` wraps `sendMessage` and `relayMessage` (once per socket) so everything that leaves is remembered — including interactive messages sent through `relayMessage`.
20
+
21
+ | | |
22
+ |---|---|
23
+ | `createSentStore({ max = 1500, ttlMs = 2 h })` | LRU + TTL |
24
+ | `save(key, message)` · `get(key)` · `size()` | manual use |
25
+ | `install(sock, { onSent, onIncoming, log })` | returns the socket |
26
+
27
+ With [`pool`](../pool), build one store per socket inside `socketConfig`, as shown in the [guide](../../docs/building-a-bot.md#putting-it-together).
@@ -0,0 +1,65 @@
1
+ // @morpho/sent
2
+ //
3
+ // Baileys asks for `getMessage` in two cases: re-sending a message the recipient couldn't decrypt, and opening poll votes.
4
+ // Without a store both fail silently. This keeps what the bot sends (LRU + TTL) and installs itself on the socket.
5
+ //
6
+ // const sent = createSentStore()
7
+ // makeWASocket({ getMessage: key => sent.get(key) })
8
+ // sent.install(sock, { onSent: (jid, content, result) => ..., onIncoming: msg => ... })
9
+ function createSentStore({ max = 1500, ttlMs = 2 * 3600e3 } = {}) {
10
+ const m = new Map() // id -> { message, t }
11
+ const store = {
12
+ save(key, message) {
13
+ if (!key?.id || !message) return
14
+ m.delete(key.id)
15
+ m.set(key.id, { message, t: Date.now() })
16
+ if (m.size > max) m.delete(m.keys().next().value)
17
+ },
18
+ get(key) {
19
+ const e = m.get(key?.id)
20
+ if (!e) return undefined
21
+ if (Date.now() - e.t > ttlMs) { m.delete(key.id); return undefined }
22
+ return e.message
23
+ },
24
+ size: () => m.size,
25
+
26
+ // wraps sendMessage/relayMessage to store everything that leaves, once per socket.
27
+ // onSent(jid, content, result) called after a real message was sent (not edit/delete/react)
28
+ // onIncoming(msg) called for every message received from others (e.g. to register polls)
29
+ install(sock, { onSent, onIncoming, log = () => {} } = {}) {
30
+ if (sock.__morphoSent) return sock
31
+ sock.__morphoSent = true
32
+ const send = sock.sendMessage.bind(sock)
33
+ sock.sendMessage = async (jid, content, opts) => {
34
+ const r = await send(jid, content, opts)
35
+ try {
36
+ if (r?.key?.id && r.message && !content?.edit && !content?.delete && !content?.react) {
37
+ store.save(r.key, r.message)
38
+ onSent?.(jid, content, r)
39
+ }
40
+ } catch (e) { log("save-error", e) }
41
+ return r
42
+ }
43
+ if (typeof sock.relayMessage === "function") {
44
+ const relay = sock.relayMessage.bind(sock)
45
+ sock.relayMessage = async (jid, message, opts) => {
46
+ const r = await relay(jid, message, opts)
47
+ try { if (opts?.messageId) store.save({ id: opts.messageId }, message) } catch { /* cache only */ }
48
+ return r
49
+ }
50
+ }
51
+ if (onIncoming) {
52
+ sock.ev.on("messages.upsert", ({ messages }) => {
53
+ for (const msg of messages || []) {
54
+ if (!msg?.message || msg.key?.fromMe || !msg.key?.id) continue
55
+ try { onIncoming(msg) } catch (e) { log("incoming-error", e) }
56
+ }
57
+ })
58
+ }
59
+ return sock
60
+ }
61
+ }
62
+ return store
63
+ }
64
+
65
+ module.exports = { createSentStore }
@@ -0,0 +1 @@
1
+ { "name": "@morpho/sent", "version": "0.1.0", "description": "Short memory of the messages the bot SENT: what Baileys' getMessage asks for to re-send a message the recipient could not open and to open poll votes.", "main": "index.js", "license": "MIT" }
@@ -0,0 +1,28 @@
1
+ # morpho-socket/store-file
2
+
3
+ File-based session backend: one folder per session, one JSON file per key — the same idea as Baileys' `useMultiFileAuthState`, behind the [`MorphoAuthStore`](../core) interface.
4
+
5
+ It exists as a **migration bridge**: start here, then switch to [`store-sqlite`](../store-sqlite) by changing a single line. Nothing else in your bot notices.
6
+
7
+ ```js
8
+ const store = require("morpho-socket/store-file").create({ dir: "./auth" })
9
+ ```
10
+
11
+ Layout on disk:
12
+
13
+ ```
14
+ auth/
15
+ client-a/
16
+ creds.json
17
+ keys/<type>/<id>.json
18
+ ```
19
+
20
+ Session ids, key types and ids are sanitized to `[a-zA-Z0-9_-]` for file names.
21
+
22
+ | | |
23
+ |---|---|
24
+ | `create({ dir })` | `dir` is required |
25
+ | `listSessions()` | the folders inside `dir` |
26
+ | `deleteSession(id)` | removes the session folder |
27
+
28
+ This store does not implement `getLid` / `setLid`, so [`jid`](../jid) keeps its LID → number map in memory only. Use `store-sqlite` if you want it to persist.
@@ -0,0 +1,63 @@
1
+ // @morpho/store-file — guarda cada sessão em uma pasta, um arquivo JSON por chave.
2
+ // Existe como ponte de migração: quem já usa useMultiFileAuthState do Baileys troca pro Morpho
3
+ // sem mudar ONDE o dado mora, só muda COMO ele é acessado. Depois, trocar para @morpho/store-sqlite
4
+ // é mudar uma linha só, sem o resto do código (handler, mensagens, etc.) perceber a diferença.
5
+ const fs = require("fs")
6
+ const path = require("path")
7
+ const { MorphoAuthStore, encode, decode } = require("@morpho/core")
8
+
9
+ class FileAuthStore extends MorphoAuthStore {
10
+ constructor({ dir }) {
11
+ super()
12
+ if (!dir) throw new Error("FileAuthStore: 'dir' é obrigatório.")
13
+ this.dir = dir
14
+ }
15
+
16
+ // deixa só [a-zA-Z0-9_-] no nome de arquivo (sessionId, type e id podem ter caractere estranho)
17
+ _seguro(s) { return String(s).replace(/[^a-zA-Z0-9_-]/g, "_") }
18
+ _pastaSessao(sessionId) { return path.join(this.dir, this._seguro(sessionId)) }
19
+ _arqCreds(sessionId) { return path.join(this._pastaSessao(sessionId), "creds.json") }
20
+ _arqChave(sessionId, type, id) { return path.join(this._pastaSessao(sessionId), "keys", this._seguro(type), `${this._seguro(id)}.json`) }
21
+
22
+ async _ler(arq) {
23
+ try { return decode(fs.readFileSync(arq, "utf8")) } catch (e) { if (e.code === "ENOENT") return null; throw e }
24
+ }
25
+ async _escrever(arq, dado) {
26
+ fs.mkdirSync(path.dirname(arq), { recursive: true })
27
+ fs.writeFileSync(arq, encode(dado))
28
+ }
29
+
30
+ async readCreds(sessionId) { return this._ler(this._arqCreds(sessionId)) }
31
+ async writeCreds(sessionId, creds) { return this._escrever(this._arqCreds(sessionId), creds) }
32
+
33
+ async readKeys(sessionId, type, ids) {
34
+ const out = {}
35
+ for (const id of ids) {
36
+ const v = await this._ler(this._arqChave(sessionId, type, id))
37
+ if (v !== null) out[id] = v
38
+ }
39
+ return out
40
+ }
41
+
42
+ async writeKeys(sessionId, writes) {
43
+ for (const type of Object.keys(writes)) {
44
+ for (const id of Object.keys(writes[type])) {
45
+ const valor = writes[type][id]
46
+ const arq = this._arqChave(sessionId, type, id)
47
+ if (valor === null) fs.rmSync(arq, { force: true }) // sem await: apagar não termina antes da próxima leitura
48
+ else await this._escrever(arq, valor)
49
+ }
50
+ }
51
+ }
52
+
53
+ async deleteSession(sessionId) {
54
+ fs.rmSync(this._pastaSessao(sessionId), { recursive: true, force: true })
55
+ }
56
+
57
+ async listSessions() {
58
+ try { return fs.readdirSync(this.dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) }
59
+ catch (e) { if (e.code === "ENOENT") return []; throw e }
60
+ }
61
+ }
62
+
63
+ module.exports = { FileAuthStore, create: opts => new FileAuthStore(opts) }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@morpho/store-file",
3
+ "version": "0.1.0",
4
+ "description": "Backend de arquivo para o Morpho (ponte de migração, mesma ideia do useMultiFileAuthState do Baileys)",
5
+ "main": "index.js",
6
+ "dependencies": { "@morpho/core": "^0.1.0" },
7
+ "license": "MIT"
8
+ }
@@ -0,0 +1,25 @@
1
+ # morpho-socket/store-sqlite
2
+
3
+ Every WhatsApp session in **one `.sqlite` file**, instead of a folder full of loose JSON files per client.
4
+
5
+ Uses Node's built-in `node:sqlite` (Node ≥ 22.5): no native compilation, no extra dependency. WAL mode is enabled and key batches are written in a transaction.
6
+
7
+ ```js
8
+ const store = require("morpho-socket/store-sqlite").create({ path: "./data/sessions.sqlite" })
9
+
10
+ const pool = new MorphoPool({ store, initCreds: initAuthCreds }) // see morpho-socket/pool
11
+ ```
12
+
13
+ | | |
14
+ |---|---|
15
+ | `create({ path })` | `path` defaults to `./morpho-auth.sqlite` |
16
+ | `store.listSessions()` | ids of every saved session |
17
+ | `store.deleteSession(id)` | removes the credentials and keys of one session |
18
+ | `store.close()` | closes the database |
19
+ | `store.getLid(lid)` / `store.setLid(lid, number)` | persistent LID → number map used by [`jid`](../jid) |
20
+
21
+ The LID map is **global, not per session**: a person's LID is the same for every number you run, so deleting or resetting a session does not erase it.
22
+
23
+ Pass the same store to `createIdentity({ store })` and the map survives restarts. Implements the [`MorphoAuthStore`](../core) interface; swap backends by changing one line.
24
+
25
+ > `node:sqlite` prints an `ExperimentalWarning` on current Node versions. It is harmless.
@@ -0,0 +1,98 @@
1
+ // @morpho/store-sqlite — todas as sessões num arquivo .sqlite só, em vez de uma pasta por cliente
2
+ // cheia de arquivo solto (o problema real que o useMultiFileAuthState tem em multi-cliente).
3
+ //
4
+ // Usa o módulo node:sqlite, nativo do Node (>= 22.5), sem dependência externa nem compilação nativa.
5
+ // Se o seu Node for mais antigo, dá pra trocar o `require("node:sqlite")` abaixo por "better-sqlite3"
6
+ // sem mudar mais nada — a classe só usa .prepare()/.run()/.get()/.all(), API bem parecida nas duas.
7
+ const { DatabaseSync } = require("node:sqlite")
8
+ const { MorphoAuthStore, encode, decode } = require("../core")
9
+
10
+ class SqliteAuthStore extends MorphoAuthStore {
11
+ constructor({ path = "./morpho-auth.sqlite" } = {}) {
12
+ super()
13
+ this.db = new DatabaseSync(path)
14
+ this.db.exec(`
15
+ PRAGMA journal_mode = WAL;
16
+ CREATE TABLE IF NOT EXISTS creds (
17
+ session_id TEXT PRIMARY KEY,
18
+ data TEXT NOT NULL
19
+ );
20
+ CREATE TABLE IF NOT EXISTS keys (
21
+ session_id TEXT NOT NULL,
22
+ type TEXT NOT NULL,
23
+ id TEXT NOT NULL,
24
+ data TEXT NOT NULL,
25
+ PRIMARY KEY (session_id, type, id)
26
+ );
27
+ -- mapa LID -> número (@morpho/jid). Global, não por sessão: o LID de uma pessoa é o mesmo pra qualquer bot,
28
+ -- então apagar/resetar uma sessão NÃO apaga isto.
29
+ CREATE TABLE IF NOT EXISTS lid_map (
30
+ lid TEXT PRIMARY KEY,
31
+ pn TEXT NOT NULL,
32
+ atualizado INTEGER NOT NULL
33
+ );
34
+ `)
35
+ this._stmt = {
36
+ getCreds: this.db.prepare("SELECT data FROM creds WHERE session_id = ?"),
37
+ setCreds: this.db.prepare("INSERT INTO creds (session_id, data) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET data = excluded.data"),
38
+ delKey: this.db.prepare("DELETE FROM keys WHERE session_id = ? AND type = ? AND id = ?"),
39
+ setKey: this.db.prepare("INSERT INTO keys (session_id, type, id, data) VALUES (?, ?, ?, ?) ON CONFLICT(session_id, type, id) DO UPDATE SET data = excluded.data"),
40
+ delSessionCreds: this.db.prepare("DELETE FROM creds WHERE session_id = ?"),
41
+ delSessionKeys: this.db.prepare("DELETE FROM keys WHERE session_id = ?"),
42
+ listSessions: this.db.prepare("SELECT DISTINCT session_id FROM creds"),
43
+ getLid: this.db.prepare("SELECT pn FROM lid_map WHERE lid = ?"),
44
+ setLid: this.db.prepare("INSERT INTO lid_map (lid, pn, atualizado) VALUES (?, ?, ?) ON CONFLICT(lid) DO UPDATE SET pn = excluded.pn, atualizado = excluded.atualizado")
45
+ }
46
+ }
47
+
48
+ async readCreds(sessionId) {
49
+ const row = this._stmt.getCreds.get(sessionId)
50
+ return row ? decode(row.data) : null
51
+ }
52
+
53
+ async writeCreds(sessionId, creds) {
54
+ this._stmt.setCreds.run(sessionId, encode(creds))
55
+ }
56
+
57
+ async readKeys(sessionId, type, ids) {
58
+ if (!ids.length) return {}
59
+ // IN (?,?,?) dinâmico — node:sqlite não aceita array direto num placeholder só
60
+ const stmt = this.db.prepare(`SELECT id, data FROM keys WHERE session_id = ? AND type = ? AND id IN (${ids.map(() => "?").join(",")})`)
61
+ const linhas = stmt.all(sessionId, type, ...ids)
62
+ const out = {}
63
+ for (const l of linhas) out[l.id] = decode(l.data)
64
+ return out
65
+ }
66
+
67
+ async writeKeys(sessionId, writes) {
68
+ // transação: o Baileys pode mandar dezenas de chaves de uma vez (ex.: sincronização inicial)
69
+ this.db.exec("BEGIN")
70
+ try {
71
+ for (const type of Object.keys(writes)) {
72
+ for (const id of Object.keys(writes[type])) {
73
+ const valor = writes[type][id]
74
+ if (valor === null) this._stmt.delKey.run(sessionId, type, id)
75
+ else this._stmt.setKey.run(sessionId, type, id, encode(valor))
76
+ }
77
+ }
78
+ this.db.exec("COMMIT")
79
+ } catch (e) { this.db.exec("ROLLBACK"); throw e }
80
+ }
81
+
82
+ async deleteSession(sessionId) {
83
+ this._stmt.delSessionCreds.run(sessionId)
84
+ this._stmt.delSessionKeys.run(sessionId)
85
+ }
86
+
87
+ async listSessions() {
88
+ return this._stmt.listSessions.all().map(r => r.session_id)
89
+ }
90
+
91
+ // --- mapa LID -> número, usado pelo @morpho/jid (opcional: quem não tem estes métodos só perde a persistência) ---
92
+ getLid(lid) { return this._stmt.getLid.get(lid)?.pn ?? null }
93
+ setLid(lid, pn) { this._stmt.setLid.run(lid, pn, Date.now()) }
94
+
95
+ close() { this.db.close() }
96
+ }
97
+
98
+ module.exports = { SqliteAuthStore, create: opts => new SqliteAuthStore(opts) }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@morpho/store-sqlite",
3
+ "version": "0.1.0",
4
+ "description": "Backend de SQLite para o Morpho: várias sessões num arquivo só, sem pasta cheia de arquivinho.",
5
+ "main": "index.js",
6
+ "dependencies": { "@morpho/core": "^0.1.0" },
7
+ "engines": { "node": ">=22.5.0" },
8
+ "license": "MIT"
9
+ }