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,132 @@
1
+ // @morpho/context — the "g" every command receives: ONE object with everything about the current message.
2
+ //
3
+ // const { createContext } = require("@morpho/context")
4
+ // const contexts = createContext({
5
+ // identity, // createIdentity() from @morpho/jid (required)
6
+ // media, interactive, // createMedia() / createInteractive() (optional: g.download*, buttons)
7
+ // getGroupMeta: (sock, jid) => groups.get(jid), // default sock.groupMetadata(jid) (use @morpho/groups for cache)
8
+ // isOwner: g => g.user.phone === "5511…", // default: only the bot's own device
9
+ // protect: (g, person) => boolean, // extra untouchables for g.kick / g.demote (the bot owner...)
10
+ // prefixFor: (g, jid) => "!", prefix: "/",
11
+ // names: { owner: "Dono", user: "Alguém" },
12
+ // extend: { get vip() { return this.client?.vip }, myHelper() {} } // the bot's own getters/methods
13
+ // })
14
+ // const g = await contexts.build({ sock, msg, clientId, ...anythingElse }) // extra props are copied onto g
15
+ //
16
+ // The shape of g (details: README):
17
+ // message g.text g.type g.id g.isGroup g.isPrivate g.chatId g.quoted g.mentions g.command g.args g.argText g.prefix
18
+ // sender g.user { jid lid phone number name ... } g.isOwner await g.isAdmin() await g.isBotAdmin()
19
+ // people await g.who(x?) -> Person | null Person = { jid, lid, phone, number, admin, superadmin, bot }
20
+ // group await g.group() g.members() g.admins() g.kick g.promote g.demote g.add g.close g.open g.link ...
21
+ // send g.reply g.send g.at g.react g.edit g.delete g.poll
22
+ // media g.media() g.hasMedia await g.download() await g.downloadInfo()
23
+ const { parse } = require("../message")
24
+ const { createGroupActions, REASONS } = require("./group")
25
+ const { createSend } = require("./send")
26
+ const { userPart, participantIds, isAdminRole, botIds } = require("./participants")
27
+ const { normalize, digits } = require("../jid")
28
+
29
+ function createContext({ identity, media, interactive, getGroupMeta, isOwner, protect, prefixFor, prefix = "/", names = {}, interactiveEnabled = () => true, extend = {} } = {}) {
30
+ if (!identity) throw new Error("createContext: 'identity' is required (createIdentity from @morpho/jid)")
31
+ const fetchMeta = getGroupMeta || ((sock, jid) => sock.groupMetadata(jid))
32
+ const nameOf = { owner: "Owner", user: "User", ...names }
33
+
34
+ const proto = {
35
+ msg: null, sock: null, identity: null, clientId: null, user: null,
36
+
37
+ // ----- the message -----
38
+ get parsed() { return parse(this.msg) },
39
+ get id() { return this.msg?.key?.id || null },
40
+ get type() { return this.parsed.type },
41
+ is(...types) { return types.includes(this.parsed.type) },
42
+ get chatId() { return this.msg?.key?.remoteJid || null },
43
+ get isGroup() { return this.parsed.isGroup },
44
+ get isPrivate() { return !this.parsed.isGroup && !this.parsed.isStatus && !this.parsed.isNewsletter },
45
+ get isFromMe() { return this.parsed.fromMe },
46
+ get timestamp() { return this.parsed.timestamp },
47
+ get mentions() { return this.parsed.mentions },
48
+ get quoted() { return this.parsed.quoted }, // parsed quoted message (text, media, sender...) or null
49
+ get readMore() { return "\u200E".repeat(4001) }, // `${a}${g.readMore}${b}` -> WhatsApp folds b behind "Read more"
50
+
51
+ // text/caption; a tap on one of OUR buttons/list rows counts as the command it carries (e.g. "/menu games")
52
+ get text() { return this.parsed.text || this.interactiveReply || null },
53
+ get interactiveReply() { return interactive ? interactive.parseSelection(this.msg, { sock: this.sock }) : null },
54
+
55
+ // the group's prefix, and the command split: "!ban @x motivo" -> { prefix, name: "ban", args: ["@x","motivo"], argText: "@x motivo" }
56
+ get prefix() { return prefixFor ? prefixFor(this, this.chatId) : prefix },
57
+ get command() {
58
+ const t = this.text, p = this.prefix
59
+ if (!t || !p || !t.startsWith(p)) return null
60
+ const rest = t.slice(p.length).trim()
61
+ if (!rest) return null
62
+ const [name, ...args] = rest.split(/ +/)
63
+ return { prefix: p, name: name.toLowerCase(), args, argText: rest.slice(name.length).trim() }
64
+ },
65
+ get args() { return this.command?.args || [] },
66
+ get argText() { return this.command?.argText || "" },
67
+
68
+ // ----- permissions -----
69
+ get isOwner() { return isOwner ? !!isOwner(this) : this.isFromMe },
70
+ async groupMeta() { return this.isGroup ? fetchMeta(this.sock, this.chatId) : null },
71
+ async isAdmin() {
72
+ if (!this.isGroup) return false
73
+ const meta = await this.groupMeta()
74
+ if (!meta?.participants) return false
75
+ const mine = [this.identity?.jid, this.identity?.lid, this.identity?.pn].filter(Boolean).map(normalize).filter(Boolean)
76
+ if (!mine.length) return false
77
+ return isAdminRole(meta.participants.find(p => participantIds(p).some(x => mine.includes(x))))
78
+ },
79
+ async isBotAdmin() {
80
+ if (!this.isGroup) return false
81
+ const meta = await this.groupMeta()
82
+ if (!meta?.participants) return false
83
+ const me = botIds(this.sock)
84
+ return isAdminRole(meta.participants.find(p => participantIds(p).some(x => me.includes(x))))
85
+ },
86
+
87
+ // ----- media (incoming) -----
88
+ // opts: { types: ["image","video","audio"], max (bytes), quoted: "prefer"|"only"|"never", viewOnce: false }
89
+ // The QUOTED media wins over the message's own. View-once only with viewOnce: true (else anyone could reveal it with /s).
90
+ media({ types = ["image", "video", "audio"], quoted, viewOnce = false } = {}) { // look only (type, seconds, size...) -> info | null
91
+ if (!media) throw new Error("g.media: pass 'media' to createContext")
92
+ if (!this.msg?.message) return null
93
+ const found = media.find(this.msg, { types, quoted })
94
+ return !found || (found.viewOnce && !viewOnce) ? null : found
95
+ },
96
+ get hasMedia() { try { return !!this.media() } catch { return false } },
97
+ downloadFound(found, { max } = {}) { return media.downloadFound(found, { sock: this.sock, max }) },
98
+ async downloadInfo(opts) { const f = this.media(opts); return f ? this.downloadFound(f, { max: opts?.max }) : null }, // { buffer, type, mimetype, ... } | null
99
+ async download(opts) { return (await this.downloadInfo(opts))?.buffer ?? null }, // Buffer | null. Errors carry .code (MEDIA_TOO_LARGE, MEDIA_EXPIRED...)
100
+ }
101
+
102
+ // sending (reply/send/at/react/edit/poll) and group actions (kick/promote/.../who/group/members)
103
+ const mix = o => Object.defineProperties(proto, Object.getOwnPropertyDescriptors(o))
104
+ mix(createSend({ interactive, interactiveEnabled }))
105
+ mix(createGroupActions({ identity, protect }))
106
+ // the bot's own getters/methods (descriptors are copied so getters stay getters)
107
+ mix(extend)
108
+
109
+ // props: { sock, msg, clientId, ...anything }. Resolves the sender ONCE; never throws.
110
+ async function build({ sock, msg, clientId, ...rest }) {
111
+ const g = Object.create(proto)
112
+ Object.assign(g, rest)
113
+ g.msg = msg; g.sock = sock; g.clientId = clientId
114
+ const id = g.identity = await identity.resolveSender(msg, sock, clientId)
115
+ const lid = id?.lid || null, jid = id?.jid || null
116
+ g.user = Object.freeze({
117
+ jid, lid,
118
+ phone: id?.number || null, // the real phone number, or null (LID not resolved yet)
119
+ hasPhone: !!id?.number,
120
+ number: id?.number || (lid ? userPart(lid) : null) || (jid ? userPart(jid) : null), // never empty when there is a sender: the key to use in your database
121
+ formatted: id?.formatted || null,
122
+ source: id?.source || null,
123
+ name: g.isFromMe ? nameOf.owner : (msg?.pushName || nameOf.user)
124
+ })
125
+ media?.bind(msg, sock) // if the link expires, @morpho/media asks the phone to re-send through this sock
126
+ return g
127
+ }
128
+
129
+ return { proto, build }
130
+ }
131
+
132
+ module.exports = { createContext, participantIds, isAdminRole, GROUP_REASONS: REASONS, digits }
@@ -0,0 +1 @@
1
+ { "name": "@morpho/context", "version": "0.2.0", "description": "The per-message context ('g') every command receives: who sent it (LID resolved to a number), text, mentions, quoted message, permissions, target/mention helpers, reply/send helpers and media download. Built on @morpho/message, jid, groups, reply, media and interactive.", "main": "index.js", "dependencies": { "@morpho/message": "file:../message", "@morpho/reply": "file:../reply", "@morpho/jid": "file:../jid" }, "license": "MIT" }
@@ -0,0 +1,12 @@
1
+ // @morpho/context — helpers shared by index.js (the "g") and group.js (group actions).
2
+ // Everything here is pure: no sock, no I/O.
3
+ const { normalize } = require("../jid")
4
+
5
+ const userPart = j => String(j).split("@")[0].split(":")[0]
6
+ const cleanJid = j => (j ? j.replace(/:\d+(?=@)/, "") : null)
7
+ // every id a group participant can have (Baileys 7: id, lid, phoneNumber), normalized
8
+ const participantIds = p => [p?.id, p?.lid, p?.phoneNumber, p?.jid].filter(x => typeof x === "string").map(normalize).filter(Boolean)
9
+ const isAdminRole = p => p?.admin === "admin" || p?.admin === "superadmin"
10
+ const botIds = sock => [sock?.user?.id, sock?.user?.lid].filter(Boolean).map(normalize).filter(Boolean)
11
+
12
+ module.exports = { userPart, cleanJid, participantIds, isAdminRole, botIds }
@@ -0,0 +1,127 @@
1
+ // @morpho/context/send — everything the "g" SENDS, through one door.
2
+ //
3
+ // await g.reply("oi") // quotes the command, keeps mentions
4
+ // await g.reply({ image: buf, caption: "olha" }) // image | video | gif | sticker | voice | audio | document
5
+ // await g.reply({ body: "Escolha", buttons: [...], fallback: "texto se não der" }) // buttons/list (needs @morpho/interactive)
6
+ // await g.send(jid, "oi") // any other chat (does not quote unless { quote: true })
7
+ // await g.reply(`parabéns ${await g.at(person)}!`) // g.at() writes "@123" AND remembers the mention: it just works
8
+ // const sent = await g.reply("calculando..."); await g.edit(sent, "pronto!"); await g.delete(sent)
9
+ //
10
+ // Every method returns the sent WhatsApp message (with .key). Options (2nd arg, or inside the content object):
11
+ // mentions: [anything g.at() understands] quote: false | true | someMsg mimetype / fileName (document, audio)
12
+ // Sources (image, video, gif, sticker, voice, audio, document): Buffer | "https://..." | "/path/file" | { url }.
13
+ const fs = require("fs")
14
+ const reply = require("../reply")
15
+ const { normalize, digits } = require("../jid")
16
+ const { userPart, participantIds, cleanJid } = require("./participants")
17
+
18
+ const MEDIA_KINDS = ["image", "video", "gif", "sticker", "voice", "audio", "document"]
19
+
20
+ function source(x) {
21
+ if (Buffer.isBuffer(x)) return x
22
+ if (typeof x === "string") return /^https?:\/\//i.test(x) ? { url: x } : fs.readFileSync(x)
23
+ if (x && typeof x === "object" && x.url) return x
24
+ throw new Error("invalid media source (use a Buffer, a path, a URL or { url })")
25
+ }
26
+ const needBuffer = (x, what) => { const s = source(x); if (!Buffer.isBuffer(s)) throw new Error(`${what}: needs a Buffer or a local file (not a URL)`); return s }
27
+
28
+ function createSend({ interactive, interactiveEnabled = () => true } = {}) {
29
+ // anything -> the participant's NATIVE jid (@lid in LID groups): the one WhatsApp swaps for the contact's name
30
+ async function nativeJid(g, input) {
31
+ if (input === undefined || input === null) input = g.user
32
+ if (typeof input === "object") input = input.jid || input.lid || (input.phone && `${input.phone}@s.whatsapp.net`) || input.number
33
+ if (typeof input !== "string") return null
34
+ const jid = input.includes("@") ? cleanJid(input) : (digits(input) ? `${digits(input)}@s.whatsapp.net` : null)
35
+ if (!jid) return null
36
+ if (!g.isGroup) return jid
37
+ const meta = await g.groupMeta().catch(() => null)
38
+ const p = meta?.participants?.find(p => participantIds(p).includes(normalize(jid)))
39
+ return p?.id || jid
40
+ }
41
+
42
+ async function collect(g, explicit = [], text = "") {
43
+ const out = new Set()
44
+ for (const m of explicit) { const j = await nativeJid(g, m); if (j) out.add(j) }
45
+ const tags = g._tags
46
+ if (tags?.size && text) for (const m of String(text).matchAll(/@(\d+)/g)) if (tags.has(m[1])) out.add(tags.get(m[1]))
47
+ return [...out]
48
+ }
49
+
50
+ // content -> { kind, text, body... }
51
+ function classify(c) {
52
+ if (typeof c === "string" || typeof c === "number") return { kind: "text", text: String(c) }
53
+ if (!c || typeof c !== "object") throw new Error("reply/send: content must be a string or an object")
54
+ if (c.buttons) return { kind: "interactive", text: c.fallback ?? c.body ?? c.text ?? "" }
55
+ for (const k of MEDIA_KINDS) if (c[k] !== undefined && c[k] !== null) return { kind: k, text: c.caption ?? c.text ?? "" }
56
+ return { kind: "text", text: c.text ?? "" }
57
+ }
58
+
59
+ async function deliver(g, to, c, defaults = {}) {
60
+ const x = classify(c)
61
+ const o = typeof c === "object" ? c : {}
62
+ const wantQuote = o.quote ?? defaults.quote
63
+ const quoted = wantQuote && wantQuote !== true ? wantQuote : (wantQuote && to === g.chatId ? g.msg : null)
64
+ const mentions = await collect(g, [...(defaults.mentions || []), ...(o.mentions || [])], x.text + " " + (o.body || ""))
65
+ const sock = g.sock
66
+ const plain = () => sock.sendMessage(to, { text: x.text, mentions })
67
+ const sendText = async () => {
68
+ if (!quoted) return plain()
69
+ try { return await reply.reply(sock, to, quoted, x.text, { mentions }) } catch { return plain() } // quoting failed (e.g. button tap)? send loose
70
+ }
71
+
72
+ switch (x.kind) {
73
+ case "text": return sendText()
74
+
75
+ case "interactive": {
76
+ if (interactive && interactiveEnabled()) {
77
+ try { return await interactive.send(sock, to, { body: o.body ?? o.text, footer: o.footer || "", buttons: o.buttons, mentions, image: o.image, imageId: o.imageId, msg: quoted || undefined }) }
78
+ catch (e) { console.log("[morpho] interactive failed, using text:", e?.message) }
79
+ }
80
+ return sendText() // the command is never mute
81
+ }
82
+
83
+ case "image": case "video": case "gif": case "document": {
84
+ const caption = o.caption ?? o.text ?? ""
85
+ const content = x.kind === "gif" ? { video: source(o.gif), gifPlayback: true, caption, mentions }
86
+ : x.kind === "document" ? { document: source(o.document), mimetype: o.mimetype || "application/octet-stream", fileName: o.fileName || "file", caption, mentions }
87
+ : { [x.kind]: source(o[x.kind]), caption, mentions }
88
+ const onError = e => console.log(`[morpho] ${x.kind} send failed:`, e?.message)
89
+ if (quoted) return reply.replyMedia(sock, to, quoted, content, { text: caption, mentions, onError })
90
+ try { return await sock.sendMessage(to, content) }
91
+ catch (e) { onError(e); if (caption) return plain(); throw e }
92
+ }
93
+
94
+ case "sticker": return reply.sendSticker(sock, to, needBuffer(o.sticker, "sticker"), { quoted: quoted || undefined })
95
+ case "voice": return reply.sendVoice(sock, to, needBuffer(o.voice, "voice"), { quoted: quoted || undefined })
96
+ case "audio": return sock.sendMessage(to, { audio: source(o.audio), mimetype: o.mimetype || "audio/mpeg" }, quoted ? { quoted } : undefined)
97
+ }
98
+ }
99
+
100
+ return {
101
+ // "@123" for a person (Person, number, jid, g.user; nothing = whoever sent the command) AND registers the mention.
102
+ async at(input) {
103
+ const jid = await nativeJid(this, input)
104
+ if (!jid) return ""
105
+ const id = userPart(jid)
106
+ ;(this._tags ||= new Map()).set(id, jid)
107
+ return "@" + id
108
+ },
109
+ mentionJid(input) { return nativeJid(this, input) },
110
+
111
+ reply(content, opts = {}) { return deliver(this, this.chatId, typeof content === "object" && content ? { ...opts, ...content } : { ...opts, text: content }, { quote: true, mentions: opts.mentions }) },
112
+ send(to, content, opts = {}) { return deliver(this, to, typeof content === "object" && content ? { ...opts, ...content } : { ...opts, text: content }, { quote: false, mentions: opts.mentions }) },
113
+
114
+ react(emoji, target) {
115
+ const key = target?.key || target || this.msg?.key
116
+ return this.sock.sendMessage(key?.remoteJid || this.chatId, { react: { text: emoji, key } })
117
+ },
118
+ // edit a message the bot sent (what reply/send returned)
119
+ edit(sent, text) {
120
+ const key = sent?.key || sent
121
+ return this.sock.sendMessage(key?.remoteJid || this.chatId, { text: String(text), edit: key })
122
+ },
123
+ poll(question, options = [], { to, multiple = false } = {}) { return reply.sendPoll(this.sock, to || this.chatId, question, options, { selectableCount: multiple ? options.length : 1 }) }
124
+ }
125
+ }
126
+
127
+ module.exports = { createSend }
@@ -0,0 +1,50 @@
1
+ # morpho-socket/core
2
+
3
+ The session layer. Baileys defines the *format* of a session (credentials + Signal Protocol keys); Morpho only decides **where it is stored**.
4
+
5
+ ```js
6
+ const { useMorphoAuthState } = require("morpho-socket/core")
7
+ const { initAuthCreds, makeWASocket } = require("@whiskeysockets/baileys")
8
+ const store = require("morpho-socket/store-sqlite").create({ path: "./data/sessions.sqlite" })
9
+
10
+ const { state, saveCreds } = await useMorphoAuthState(store, "client-a", { initCreds: initAuthCreds })
11
+
12
+ const sock = makeWASocket({ auth: state })
13
+ sock.ev.on("creds.update", saveCreds)
14
+ ```
15
+
16
+ `useMorphoAuthState(store, sessionId, { initCreds })` is a drop-in replacement for Baileys' `useMultiFileAuthState()`:
17
+
18
+ - creates the credentials with `initCreds()` the first time and saves them;
19
+ - keeps an in-memory cache in front of the store (Baileys reads and writes keys many times per message);
20
+ - understands "delete this key" (`null`), which Baileys relies on.
21
+
22
+ Morpho never generates cryptographic material: `initCreds` always comes from Baileys.
23
+
24
+ > If you use [`pool`](../pool) you will never call this yourself — the pool does it for every session.
25
+
26
+ ## Writing your own backend
27
+
28
+ A store is any object with these methods (duck typing works; extending `MorphoAuthStore` only gives you clear "not implemented" errors):
29
+
30
+ ```js
31
+ readCreds(sessionId) // -> saved object, or null if the session does not exist
32
+ writeCreds(sessionId, creds) // -> void
33
+ readKeys(sessionId, type, ids) // -> { [id]: data } (ids with no saved value are left out)
34
+ writeKeys(sessionId, writes) // -> void writes = { [type]: { [id]: data | null } }, null = delete
35
+ deleteSession(sessionId) // -> void removes creds and every key of the session
36
+ listSessions() // -> string[]
37
+ ```
38
+
39
+ Optionally add `getLid(lid)` / `setLid(lid, number)` and [`jid`](../jid) will persist its LID → number map in your store.
40
+
41
+ ## Exports
42
+
43
+ | | |
44
+ |---|---|
45
+ | `useMorphoAuthState(store, sessionId, { initCreds })` | → `{ state, saveCreds }` |
46
+ | `MorphoAuthStore` | base class documenting the interface |
47
+ | `encode(obj)` / `decode(text)` | JSON with `Buffer`/`Uint8Array` ⇄ base64, so keys survive serialization |
48
+ | `loadBaileys()` | loads `@whiskeysockets/baileys` from **your** project (cwd / entry script folder) |
49
+
50
+ See also: [`store-sqlite`](../store-sqlite), [`store-file`](../store-file).
@@ -0,0 +1,25 @@
1
+ // Loads modules from the BOT's project, not from where Morpho lives.
2
+ //
3
+ // Morpho is usually installed as a sibling folder (file:../morpho/packages/*). Node resolves a symlinked package from its
4
+ // REAL path, so a plain require("pino") inside ../morpho/packages/pool looks in ../morpho/node_modules and misses the
5
+ // bot's own node_modules. This tries the normal way first, then falls back to the bot's folder
6
+ // (the process working directory and the entry script's folder).
7
+ function loadFromBot(name) {
8
+ try { return require(name) } catch (e) { if (e.code !== "MODULE_NOT_FOUND" || !String(e.message).includes(`'${name}'`)) throw e }
9
+ const paths = [process.cwd(), require.main?.path].filter(Boolean)
10
+ try { return require(require.resolve(name, { paths })) }
11
+ catch { throw new Error(`${name} was not found. Install it in the bot's project (npm install ${name}). Looked in: ${paths.join(", ")}`) }
12
+ }
13
+
14
+ const loadBaileys = () => loadFromBot("@whiskeysockets/baileys")
15
+
16
+ // Baileys logger: pino if the bot has it, else a silent stand-in with the same shape
17
+ function silentLogger() {
18
+ try { return loadFromBot("pino")({ level: "silent" }) } catch { /* no pino: stub below */ }
19
+ const noop = () => {}
20
+ const l = { level: "silent", trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }
21
+ l.child = () => l
22
+ return l
23
+ }
24
+
25
+ module.exports = { loadFromBot, loadBaileys, silentLogger }
@@ -0,0 +1,17 @@
1
+ // Serialização de creds/chaves para JSON. O Baileys guarda Buffer/Uint8Array (chaves do Signal
2
+ // Protocol) dentro desses objetos, e JSON puro não suporta binário — vira um objeto vazio "{}".
3
+ // Mesma ideia do BufferJSON interno do Baileys: binário vira { type: "Buffer", data: "<base64>" }
4
+ // na hora de salvar, e volta a ser Buffer de verdade na hora de ler.
5
+ function replacer(_key, value) {
6
+ if (value && (Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === "Buffer"))
7
+ return { type: "Buffer", data: Buffer.from(value.data ?? value).toString("base64") }
8
+ return value
9
+ }
10
+ function reviver(_key, value) {
11
+ if (value && typeof value === "object" && value.type === "Buffer" && typeof value.data === "string")
12
+ return Buffer.from(value.data, "base64")
13
+ return value
14
+ }
15
+ const encode = obj => JSON.stringify(obj, replacer)
16
+ const decode = txt => (txt === undefined || txt === null ? null : JSON.parse(txt, reviver))
17
+ module.exports = { encode, decode }
@@ -0,0 +1,86 @@
1
+ // @morpho/core
2
+ //
3
+ // O Baileys já define o FORMATO da sessão (creds + chaves do Signal Protocol); o Morpho só define
4
+ // ONDE isso fica guardado. Um "MorphoAuthStore" é qualquer objeto com estes 5 métodos (veja store.js
5
+ // para a classe-base com erro explicativo em quem esquecer de implementar algum).
6
+ //
7
+ // readCreds(sessionId) -> objeto salvo, ou null/undefined se não existe
8
+ // writeCreds(sessionId, creds) -> void
9
+ // readKeys(sessionId, type, ids) -> { [id]: dado } (ids sem valor salvo ficam de fora do objeto)
10
+ // writeKeys(sessionId, writes) -> void (writes = { [type]: { [id]: dado|null } }; null = apagar)
11
+ // deleteSession(sessionId) -> void (apaga creds + todas as chaves da sessão)
12
+ //
13
+ // Quem usa (ex.: sessoes.js do Morvyn) troca só a linha do useMultiFileAuthState:
14
+ //
15
+ // const { useMorphoAuthState } = require("@morpho/core")
16
+ // const { initAuthCreds } = require("@whiskeysockets/baileys")
17
+ // const { state, saveCreds } = await useMorphoAuthState(store, ctx.id, { initCreds: initAuthCreds })
18
+ // const sock = makeWaSocket({ auth: state, ... })
19
+ // sock.ev.on("creds.update", saveCreds)
20
+ //
21
+ // O Morpho NUNCA gera chave criptográfica: initCreds vem do próprio Baileys (initAuthCreds), porque
22
+ // é geração de chave do Signal Protocol — isso é trabalho do Baileys, não do Morpho.
23
+
24
+ async function useMorphoAuthState(store, sessionId, { initCreds } = {}) {
25
+ if (!store || typeof store.readCreds !== "function")
26
+ throw new Error("useMorphoAuthState: o primeiro argumento precisa ser um MorphoAuthStore (veja @morpho/core/store.js).")
27
+ if (!sessionId) throw new Error("useMorphoAuthState: sessionId é obrigatório.")
28
+
29
+ let creds = await store.readCreds(sessionId)
30
+ if (!creds) {
31
+ if (typeof initCreds !== "function")
32
+ throw new Error(
33
+ `useMorphoAuthState: nenhuma sessão salva para "${sessionId}" e nenhum initCreds foi passado. ` +
34
+ `Passe { initCreds: require("@whiskeysockets/baileys").initAuthCreds }.`
35
+ )
36
+ creds = initCreds()
37
+ await store.writeCreds(sessionId, creds)
38
+ }
39
+
40
+ // cache em memória: evita reler do backend uma chave que acabamos de escrever ou já lemos nesta sessão
41
+ // (o Baileys chama keys.get() e keys.set() MUITAS vezes por mensagem; isso poupa leitura de disco/rede)
42
+ const cache = new Map() // `${type}:${id}` -> dado | null (null = "sabemos que não existe")
43
+ const chave = (tipo, id) => `${tipo}:${id}`
44
+
45
+ const keys = {
46
+ async get(type, ids) {
47
+ const resultado = {}
48
+ const faltando = []
49
+ for (const id of ids) {
50
+ const k = chave(type, id)
51
+ if (cache.has(k)) { const v = cache.get(k); if (v !== null) resultado[id] = v }
52
+ else faltando.push(id)
53
+ }
54
+ if (faltando.length) {
55
+ const lidos = await store.readKeys(sessionId, type, faltando)
56
+ for (const id of faltando) {
57
+ const v = lidos?.[id]
58
+ if (v === undefined) { cache.set(chave(type, id), null); continue }
59
+ cache.set(chave(type, id), v)
60
+ resultado[id] = v
61
+ }
62
+ }
63
+ return resultado
64
+ },
65
+ async set(data) {
66
+ const writes = {}
67
+ for (const type of Object.keys(data)) {
68
+ writes[type] = {}
69
+ for (const id of Object.keys(data[type])) {
70
+ const valor = data[type][id]
71
+ cache.set(chave(type, id), valor === null || valor === undefined ? null : valor)
72
+ writes[type][id] = valor ?? null // null = apagar, é o que o Baileys espera poder pedir
73
+ }
74
+ }
75
+ await store.writeKeys(sessionId, writes)
76
+ }
77
+ }
78
+
79
+ const saveCreds = () => store.writeCreds(sessionId, creds)
80
+
81
+ return { state: { creds, keys }, saveCreds }
82
+ }
83
+
84
+ const { loadBaileys } = require("./baileys")
85
+ module.exports = {
86
+ loadBaileys, useMorphoAuthState, MorphoAuthStore: require("./store").MorphoAuthStore, ...require("./codec") }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@morpho/core",
3
+ "version": "0.1.0",
4
+ "description": "Backend de sessão plugável para Baileys (WhatsApp). Camada 1 do projeto Morpho.",
5
+ "main": "index.js",
6
+ "license": "MIT"
7
+ }
@@ -0,0 +1,11 @@
1
+ // Classe-base só para documentar a interface e falhar com uma mensagem clara em quem esquecer
2
+ // de implementar algum método — não é obrigatório herdar dela (duck typing funciona normal).
3
+ class MorphoAuthStore {
4
+ async readCreds(sessionId) { throw new Error("MorphoAuthStore.readCreds não implementado") }
5
+ async writeCreds(sessionId, creds) { throw new Error("MorphoAuthStore.writeCreds não implementado") }
6
+ async readKeys(sessionId, type, ids) { throw new Error("MorphoAuthStore.readKeys não implementado") }
7
+ async writeKeys(sessionId, writes) { throw new Error("MorphoAuthStore.writeKeys não implementado") }
8
+ async deleteSession(sessionId) { throw new Error("MorphoAuthStore.deleteSession não implementado") }
9
+ async listSessions() { throw new Error("MorphoAuthStore.listSessions não implementado") }
10
+ }
11
+ module.exports = { MorphoAuthStore }
@@ -0,0 +1,42 @@
1
+ # morpho-socket/groups
2
+
3
+ A **group metadata cache**, per socket. Without it every message sent to a group and every "is this person an admin?" check goes to WhatsApp — the bot gets slow and the number gets closer to rate limits.
4
+
5
+ ```js
6
+ const { createGroups } = require("morpho-socket/groups")
7
+ const groups = createGroups({ ttlMs: 120_000 })
8
+
9
+ const sock = makeWASocket({ /* … */, cachedGroupMetadata: groups.cachedGroupMetadata }) // can be created before the socket exists
10
+ groups.install(sock) // sock.groupMetadata(jid) now uses the cache
11
+ await groups.loadAll() // optional, on connect: one query fills every group
12
+
13
+ await groups.get(jid) // metadata (cached or one query); null if the jid is not a group
14
+ await groups.isAdmin(jid, userJid, userLid)
15
+ ```
16
+
17
+ `install(sock)` replaces `sock.groupMetadata` with the cached version (the original stays available as `sock.groupMetadataFresh`) and is idempotent. Create **one instance per socket**.
18
+
19
+ ## What it solves
20
+
21
+ | Problem | Solution |
22
+ |---|---|
23
+ | The same lookup, over and over | TTL cache (`ttlMs`, default 120 s; `ttl` also accepts a function so it can change at runtime) with an LRU limit (`max`, 3000) |
24
+ | 50 messages arrive in a new group at once | concurrent requests become **one** query |
25
+ | WhatsApp refuses the query (rate limit) | serves stale data up to `maxStaleMs` (30 min); with no stale data the error propagates |
26
+ | Bot was removed / group unreachable | after an error with no stale data the group "rests" for `errorTtlMs` (5 s): 50 messages = 1 query, not 50 |
27
+ | The group changed | `groups.update`, `group-participants.update`, and any `groupParticipantsUpdate` / `groupSettingUpdate` / … made by the bot drop that group's cache. `groups.upsert` (bot joined) fills it |
28
+ | A query that came back already outdated | if the group changed while it was in flight, the result is **not** cached |
29
+
30
+ ## API
31
+
32
+ `createGroups(options)` → `{ install, get, admins, isAdmin, loadAll, cachedGroupMetadata, invalidate, set, clear, stats, cache }`
33
+
34
+ - `admins(jid)` → `Set` of every id (phone, LID, device-less) of the group's admins
35
+ - `isAdmin(jid, ...ids)` → understands `@lid`, phone JIDs, the `:12` device suffix and Baileys 7's `lid` / `phoneNumber` fields
36
+ - `install(sock)` returns a handle with the same methods plus `uninstall()`
37
+ - `stats()` → `{ hits, fetches, coalesced, stale, errors, size }`
38
+ - Options: `ttlMs` · `ttl` · `max` · `maxStaleMs` · `errorTtlMs` · `cache` (a ready-made `createCache()`) · `intervene: false` (don't replace `sock.groupMetadata`; cache + events only)
39
+
40
+ `createCache(options)` is the pure cache (`get(jid, load)`, `set`, `invalidate`, `clear`, `stats`) with no socket involved. Also exported: `install(sock, options)` (shortcut), `isGroupJid`, `stripDevice`, `MUTATING_METHODS`.
41
+
42
+ If you use [`context`](../context), `g.isAdmin()` and the group actions read through `sock.groupMetadata`, so installing this cache speeds them up automatically.