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.
- package/README.md +155 -2
- package/docs/building-a-bot.md +402 -0
- package/package.json +35 -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 +100 -0
- package/packages/context/group.js +269 -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,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,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.
|