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,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
|
+
}
|