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,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.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @morpho/groups
|
|
2
|
+
//
|
|
3
|
+
// Cache dos dados de grupo (membros, admins, assunto) por socket. Sem ele, TODA mensagem enviada num grupo e TODA
|
|
4
|
+
// checagem "é admin?" consulta o WhatsApp: o bot fica lento e o número fica mais exposto a limite de uso.
|
|
5
|
+
//
|
|
6
|
+
// const { criarGrupos } = require("@morpho/groups")
|
|
7
|
+
// const grupos = criarGrupos({ ttlMs: 120_000 })
|
|
8
|
+
// const sock = makeWASocket({ ..., cachedGroupMetadata: grupos.cachedGroupMetadata }) // já pode passar antes do sock existir
|
|
9
|
+
// grupos.instalar(sock) // sock.groupMetadata(jid) passa a usar o cache
|
|
10
|
+
// await grupos.carregarTodos() // opcional: ao conectar, 1 consulta enche o cache de todos os grupos
|
|
11
|
+
// await grupos.obter(jid) // metadados (do cache, ou 1 consulta)
|
|
12
|
+
// await grupos.ehAdmin(jid, userJid, userLid) // true/false (entende @lid e @s.whatsapp.net)
|
|
13
|
+
//
|
|
14
|
+
// Regras:
|
|
15
|
+
// - vale ttlMs (120 s) e é descartado na hora quando o grupo muda (evento) ou quando o próprio bot muda o grupo;
|
|
16
|
+
// - N pedidos iguais ao mesmo tempo viram UMA consulta;
|
|
17
|
+
// - se o WhatsApp recusar a consulta, usa o dado antigo (até maxVelhoMs, 30 min); sem dado antigo, o erro passa;
|
|
18
|
+
// - depois de um erro sem dado antigo, o grupo "descansa" ttlErroMs (5 s) antes de ser consultado de novo
|
|
19
|
+
// (bot expulso do grupo + 50 mensagens = 1 consulta, não 50);
|
|
20
|
+
// - uma consulta que ficou velha no caminho (o grupo mudou enquanto ela voltava) nunca entra no cache.
|
|
21
|
+
|
|
22
|
+
const MARK = Symbol.for("morpho.groups")
|
|
23
|
+
const isGroupJid = jid => typeof jid === "string" && jid.endsWith("@g.us")
|
|
24
|
+
|
|
25
|
+
// métodos do socket que mudam o grupo: ao terminar (dando certo ou não), o cache daquele grupo é descartado
|
|
26
|
+
const MUTATING_METHODS = ["groupParticipantsUpdate", "groupSettingUpdate", "groupUpdateSubject", "groupUpdateDescription",
|
|
27
|
+
"groupToggleEphemeral", "groupMemberAddMode", "groupJoinApprovalMode", "groupRevokeInvite", "groupRequestParticipantsUpdate"]
|
|
28
|
+
|
|
29
|
+
// "5511@s.whatsapp.net", "5511:12@s.whatsapp.net" e "5511:12@s.whatsapp.net " viram "5511@s.whatsapp.net"
|
|
30
|
+
const stripDevice = j => (j ? String(j).trim().replace(/:\d+(?=@)/, "") : "")
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------------------------
|
|
33
|
+
// O cache em si (não conhece socket nem WhatsApp: recebe a função que busca)
|
|
34
|
+
// ---------------------------------------------------------------------------------------------
|
|
35
|
+
function createCache({ ttl, ttlMs = 120_000, max = 3000, maxStaleMs = 30 * 60_000, errorTtlMs = 5000 } = {}) {
|
|
36
|
+
const entries = new Map() // jid -> { dados, em } (a ordem do Map é a do uso: o mais antigo sai primeiro)
|
|
37
|
+
const pending = new Map() // jid -> Promise (consulta em andamento)
|
|
38
|
+
const generation = new Map() // jid -> número que sobe a cada invalidação
|
|
39
|
+
const failures = new Map() // jid -> { erro, em }
|
|
40
|
+
const stats = { hits: 0, fetches: 0, coalesced: 0, stale: 0, errors: 0 }
|
|
41
|
+
|
|
42
|
+
// `ttl` (nome antigo do Morvyn) pode ser número ou função (lida a cada consulta, para o painel mudar sem reiniciar)
|
|
43
|
+
const ttlNow = () => { const v = ttl ?? ttlMs; return typeof v === "function" ? v() : v }
|
|
44
|
+
const fresh = e => e && Date.now() - e.em < ttlNow()
|
|
45
|
+
const gen = jid => generation.get(jid) || 0
|
|
46
|
+
|
|
47
|
+
function save(jid, data) {
|
|
48
|
+
entries.delete(jid)
|
|
49
|
+
entries.set(jid, { data, em: Date.now() })
|
|
50
|
+
while (entries.size > max) entries.delete(entries.keys().next().value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function get(jid, load) {
|
|
54
|
+
const e = entries.get(jid)
|
|
55
|
+
if (fresh(e)) { stats.hits++; entries.delete(jid); entries.set(jid, e); return e.data }
|
|
56
|
+
if (pending.has(jid)) { stats.coalesced++; return pending.get(jid) }
|
|
57
|
+
|
|
58
|
+
const usableStale = e && Date.now() - e.em < maxStaleMs
|
|
59
|
+
if (e && !usableStale) entries.delete(jid)
|
|
60
|
+
|
|
61
|
+
const f = failures.get(jid)
|
|
62
|
+
if (f && Date.now() - f.em < errorTtlMs) {
|
|
63
|
+
if (usableStale) { stats.stale++; return e.data }
|
|
64
|
+
throw f.error
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const g0 = gen(jid)
|
|
68
|
+
stats.fetches++
|
|
69
|
+
const p = (async () => {
|
|
70
|
+
try {
|
|
71
|
+
const data = await load()
|
|
72
|
+
failures.delete(jid)
|
|
73
|
+
if (gen(jid) === g0) save(jid, data)
|
|
74
|
+
return data
|
|
75
|
+
} catch (error) {
|
|
76
|
+
stats.errors++
|
|
77
|
+
if (usableStale) { stats.stale++; return e.data } // o WhatsApp recusou: melhor um dado antigo do que nenhum
|
|
78
|
+
if (gen(jid) === g0) failures.set(jid, { error, em: Date.now() })
|
|
79
|
+
throw error
|
|
80
|
+
} finally {
|
|
81
|
+
if (pending.get(jid) === p) pending.delete(jid)
|
|
82
|
+
}
|
|
83
|
+
})()
|
|
84
|
+
pending.set(jid, p)
|
|
85
|
+
return p
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// dado que já veio de outra consulta (ex.: groupFetchAllParticipating): entra sem gastar consulta
|
|
89
|
+
function set(jid, data) { failures.delete(jid); save(jid, data) }
|
|
90
|
+
|
|
91
|
+
function invalidate(jid) {
|
|
92
|
+
generation.set(jid, gen(jid) + 1)
|
|
93
|
+
entries.delete(jid); pending.delete(jid); failures.delete(jid)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clear() {
|
|
97
|
+
for (const j of new Set([...entries.keys(), ...pending.keys(), ...failures.keys()])) generation.set(j, gen(j) + 1)
|
|
98
|
+
entries.clear(); pending.clear(); failures.clear()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { get, set, invalidate, clear, size: () => entries.size, stats: () => ({ ...stats, size: entries.size }) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------------------------
|
|
105
|
+
// Ligação com o socket do Baileys
|
|
106
|
+
// ---------------------------------------------------------------------------------------------
|
|
107
|
+
// criarGrupos() pode ser criado ANTES do socket (para passar o cachedGroupMetadata ao makeWASocket) e ligado depois.
|
|
108
|
+
function createGroups(options = {}) {
|
|
109
|
+
const cache = options.cache || createCache(options)
|
|
110
|
+
const intervene = options.intervene !== false // false: não troca sock.groupMetadata (só cache + eventos)
|
|
111
|
+
let sock = null
|
|
112
|
+
let fetchingAll = null // dedupe de groupFetchAllParticipating
|
|
113
|
+
let undo = []
|
|
114
|
+
|
|
115
|
+
const fetchOne = jid => {
|
|
116
|
+
if (!sock) throw new Error("@morpho/groups: chame instalar(sock) antes de consultar grupos.")
|
|
117
|
+
return (sock.groupMetadataFresh || sock.groupMetadata).call(sock, jid)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function get(jid) {
|
|
121
|
+
if (!isGroupJid(jid)) return null
|
|
122
|
+
return cache.get(jid, () => fetchOne(jid))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// o que o Baileys consulta ao ENVIAR em grupo. Nunca lança (se falhar, o Baileys pergunta ao WhatsApp sozinho).
|
|
126
|
+
async function cachedGroupMetadata(jid) {
|
|
127
|
+
if (!sock || !isGroupJid(jid)) return undefined
|
|
128
|
+
try { return (await get(jid)) ?? undefined } catch { return undefined }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Todos os grupos de uma vez (1 consulta). Enche o cache e devolve { jid: metadados }.
|
|
132
|
+
// Chamadas ao mesmo tempo viram uma só.
|
|
133
|
+
function loadAll() {
|
|
134
|
+
if (!sock) return Promise.reject(new Error("@morpho/groups: chame instalar(sock) antes de carregarTodos()."))
|
|
135
|
+
if (fetchingAll) return fetchingAll
|
|
136
|
+
const p = (async () => {
|
|
137
|
+
const all = await sock.groupFetchAllParticipating()
|
|
138
|
+
for (const [jid, meta] of Object.entries(all || {})) if (isGroupJid(jid)) cache.set(jid, meta)
|
|
139
|
+
return all || {}
|
|
140
|
+
})().finally(() => { if (fetchingAll === p) fetchingAll = null })
|
|
141
|
+
fetchingAll = p
|
|
142
|
+
return p
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Quem é admin do grupo. Aceita qualquer forma de identificar a pessoa (telefone e/ou @lid): o Baileys 7 entrega
|
|
146
|
+
// participants com id, lid e phoneNumber, e o que vale depende do grupo.
|
|
147
|
+
async function admins(jid) {
|
|
148
|
+
const meta = await get(jid)
|
|
149
|
+
const set = new Set()
|
|
150
|
+
for (const p of meta?.participants || [])
|
|
151
|
+
if (p.admin === "admin" || p.admin === "superadmin")
|
|
152
|
+
for (const j of [p.id, p.lid, p.phoneNumber, p.jid]) if (j) set.add(stripDevice(j))
|
|
153
|
+
return set
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function isAdmin(jid, ...ids) {
|
|
157
|
+
const set = await admins(jid)
|
|
158
|
+
return ids.flat().filter(Boolean).some(j => set.has(stripDevice(j)))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function install(s) {
|
|
162
|
+
if (s[MARK]) return s[MARK]
|
|
163
|
+
sock = s
|
|
164
|
+
const pristine = s.groupMetadata // para desinstalar() devolver exatamente a função que estava lá
|
|
165
|
+
const original = typeof pristine === "function" ? pristine.bind(s) : null
|
|
166
|
+
|
|
167
|
+
if (intervene && original) {
|
|
168
|
+
s.groupMetadataFresh = original
|
|
169
|
+
s.groupMetadata = jid => get(jid)
|
|
170
|
+
}
|
|
171
|
+
for (const name of MUTATING_METHODS) {
|
|
172
|
+
if (typeof s[name] !== "function") continue
|
|
173
|
+
const before = s[name]
|
|
174
|
+
const bound = before.bind(s)
|
|
175
|
+
s[name] = async (jid, ...rest) => { try { return await bound(jid, ...rest) } finally { cache.invalidate(jid) } }
|
|
176
|
+
undo.push(() => { s[name] = before })
|
|
177
|
+
}
|
|
178
|
+
if (intervene && original) undo.push(() => { s.groupMetadata = pristine; delete s.groupMetadataFresh })
|
|
179
|
+
|
|
180
|
+
const listen = (name, fn) => s.ev?.on?.(name, fn)
|
|
181
|
+
listen("groups.update", ups => { for (const u of ups || []) if (u?.id) cache.invalidate(u.id) })
|
|
182
|
+
listen("groups.upsert", metas => { for (const m of metas || []) if (m?.id) cache.set(m.id, m) })
|
|
183
|
+
listen("group-participants.update", ev => { if (ev?.id) cache.invalidate(ev.id) })
|
|
184
|
+
|
|
185
|
+
const handle = {
|
|
186
|
+
get, admins, isAdmin, loadAll, cachedGroupMetadata,
|
|
187
|
+
invalidate: jid => cache.invalidate(jid), set: (jid, meta) => cache.set(jid, meta),
|
|
188
|
+
clear: () => cache.clear(), stats: () => cache.stats(), cache,
|
|
189
|
+
uninstall() { for (const f of undo.reverse()) f(); undo = []; delete s[MARK]; sock = null }
|
|
190
|
+
}
|
|
191
|
+
s[MARK] = handle
|
|
192
|
+
return handle
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
install, get, admins, isAdmin, loadAll, cachedGroupMetadata, cache,
|
|
197
|
+
invalidate: jid => cache.invalidate(jid), set: (jid, meta) => cache.set(jid, meta),
|
|
198
|
+
clear: () => cache.clear(), stats: () => cache.stats()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// atalho: instalar(sock, { cache?, ttlMs?, ... }) -> handle (idempotente)
|
|
203
|
+
const install = (sock, options = {}) => sock[MARK] || createGroups(options).install(sock)
|
|
204
|
+
|
|
205
|
+
module.exports = { createGroups, createCache, install, isGroupJid, stripDevice, MUTATING_METHODS }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morpho/groups",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cache de metadados de grupo do WhatsApp por socket: TTL, consultas simultâneas viram uma, dado antigo quando o WhatsApp recusa, invalidação por evento e cachedGroupMetadata pronto para o Baileys.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT"
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# morpho-socket/interactive
|
|
2
|
+
|
|
3
|
+
**Native interactive messages** — image + text + buttons, and the selection list that opens to pick from (like WhatsApp Business menus) — and reading the tap.
|
|
4
|
+
|
|
5
|
+
Baileys has no ready-made `sendMessage` for this: the message is built by hand and relayed with the `biz` nodes WhatsApp needs to draw the buttons. Morpho does that for you.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { createInteractive } = require("morpho-socket/interactive")
|
|
9
|
+
const ui = createInteractive({ mark: "mybot:" }) // every id this bot sends starts with the mark
|
|
10
|
+
|
|
11
|
+
await ui.send(sock, jid, {
|
|
12
|
+
body: "Pick one", footer: "…",
|
|
13
|
+
image: buffer, imageId: "menu", // imageId: the image upload is reused for 6 h
|
|
14
|
+
buttons: [
|
|
15
|
+
ui.button.reply("Menu", "menu"), // quick reply: the bot receives the id as if typed
|
|
16
|
+
ui.button.link("Website", "https://example.com"),
|
|
17
|
+
ui.button.list("Categories", [
|
|
18
|
+
ui.section("Games", [ui.row("Tic-tac-toe", "play against me", "ttt")]),
|
|
19
|
+
]),
|
|
20
|
+
],
|
|
21
|
+
msg, // optional: quote the message that asked for the menu
|
|
22
|
+
mentions, // optional: JIDs mentioned as @number in the body
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
ui.parseSelection(msg, { sock }) // "menu" | "ttt" | … | null
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`parseSelection` returns the id **without the mark**, and `null` when the tap isn't ours: it checks the mark, and ignores taps on another bot's menu in the same group (the replied-to message must be from this bot).
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
`createInteractive({ mark = "mp:" })` → `{ mark, button, section, row, send, parseSelection, quoteOf, extraNodes }`
|
|
33
|
+
|
|
34
|
+
- `button.reply(text, id)` · `button.link(text, url)` · `button.list(title, sections)`
|
|
35
|
+
- `section(title, rows)` · `row(title, description, id)`
|
|
36
|
+
- `send(sock, jid, { body, footer, image, imageId, buttons, msg, mentions })` → the sent message
|
|
37
|
+
|
|
38
|
+
## Good to know
|
|
39
|
+
|
|
40
|
+
- This relies on WhatsApp behavior that is **not publicly documented**; it can stop working if WhatsApp changes the format. Always provide a text fallback — [`context`](../context)'s `g.reply({ body, buttons, fallback })` does: if the interactive send fails, the command is still answered in plain text.
|
|
41
|
+
- With `context`, a tap on one of your buttons becomes `g.text` (for example `"menu games"`), so it flows through your commands like typed text.
|