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,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.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @morpho/interactive
|
|
2
|
+
//
|
|
3
|
+
// "Native" interactive messages: image + text + buttons and the selection list (the "MENU CATEGORIES" that opens to pick
|
|
4
|
+
// from, like WhatsApp Business). Baileys has no ready-made sendMessage for this, so the message is hand-built
|
|
5
|
+
// (interactiveMessage + nativeFlowMessage) and sent with relayMessage plus the "biz" nodes WhatsApp needs to draw buttons.
|
|
6
|
+
//
|
|
7
|
+
// const { createInteractive } = require("@morpho/interactive")
|
|
8
|
+
// const ui = createInteractive({ mark: "mv:" }) // every id this bot sends starts with the mark
|
|
9
|
+
// await ui.send(sock, jid, { body: "Pick one", buttons: [ui.button.reply("Menu", "menu"), ui.button.link("Site", url)], msg })
|
|
10
|
+
// ui.parseSelection(msg, { sock }) // -> "menu" | null (id without the mark; null if not ours)
|
|
11
|
+
const { parse } = require("../message")
|
|
12
|
+
|
|
13
|
+
const stripDevice = j => (j ? j.replace(/:\d+(?=@)/, "") : null)
|
|
14
|
+
const userPart = j => String(j).split("@")[0].split(":")[0]
|
|
15
|
+
const IMAGE_TTL_MS = 6 * 3600e3
|
|
16
|
+
|
|
17
|
+
function createInteractive({ mark = "mp:" } = {}) {
|
|
18
|
+
const button = {
|
|
19
|
+
// opens the selection list. sections = [{ title, rows: [row(...)] }]
|
|
20
|
+
list: (title, sections) => ({ name: "single_select", buttonParamsJson: JSON.stringify({ title, sections }) }),
|
|
21
|
+
// quick reply: when tapped the bot "receives" the id (e.g. "/menuadm") as if typed
|
|
22
|
+
reply: (text, id) => ({ name: "quick_reply", buttonParamsJson: JSON.stringify({ display_text: text, id: mark + id }) }),
|
|
23
|
+
link: (text, url) => ({ name: "cta_url", buttonParamsJson: JSON.stringify({ display_text: text, url, merchant_url: url }) })
|
|
24
|
+
}
|
|
25
|
+
const section = (title, rows) => ({ title, highlight_label: "", rows })
|
|
26
|
+
const row = (title, description, id) => ({ header: "", title, description: description || "", id: mark + id })
|
|
27
|
+
|
|
28
|
+
// quotes the message of whoever asked (the "!menu" bubble above the menu)
|
|
29
|
+
function quoteOf(sock, msg) {
|
|
30
|
+
if (!msg?.key?.id || !msg.message) return null
|
|
31
|
+
const m = msg.message
|
|
32
|
+
let quotedMessage = null
|
|
33
|
+
if (m.conversation) quotedMessage = { conversation: m.conversation }
|
|
34
|
+
else if (m.extendedTextMessage) quotedMessage = { extendedTextMessage: { text: m.extendedTextMessage.text, contextInfo: { mentionedJid: m.extendedTextMessage.contextInfo?.mentionedJid || [] } } }
|
|
35
|
+
else if (m.interactiveResponseMessage) quotedMessage = { conversation: m.interactiveResponseMessage.body?.text || "" } // tap on a list/button
|
|
36
|
+
else return null
|
|
37
|
+
const participant = msg.key.fromMe ? stripDevice(sock.user?.lid || sock.user?.id) : (msg.key.participant || msg.key.remoteJid)
|
|
38
|
+
return { stanzaId: msg.key.id, participant, quotedMessage }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// extra nodes: without them the buttons don't show. In private chats WhatsApp also wants the "bot" node.
|
|
42
|
+
function extraNodes(jid) {
|
|
43
|
+
const nodes = [{ tag: "biz", attrs: {}, content: [{ tag: "interactive", attrs: { type: "native_flow", v: "1" }, content: [{ tag: "native_flow", attrs: { v: "9", name: "mixed" } }] }] }]
|
|
44
|
+
if (!String(jid).endsWith("@g.us")) nodes.push({ tag: "bot", attrs: { biz_bot: "1" } })
|
|
45
|
+
return nodes
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The menu image is the same every time: upload once per socket and keep it for 6h (it was one upload per /menu).
|
|
49
|
+
const uploaded = new WeakMap() // sock -> Map(imageId -> { media, at })
|
|
50
|
+
async function prepareImage(sock, image, imageId, prepareWAMessageMedia) {
|
|
51
|
+
const store = imageId ? (uploaded.get(sock) || uploaded.set(sock, new Map()).get(sock)) : null
|
|
52
|
+
const hit = store?.get(imageId)
|
|
53
|
+
if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return hit.media
|
|
54
|
+
const media = await prepareWAMessageMedia({ image }, { upload: sock.waUploadToServer })
|
|
55
|
+
store?.set(imageId, { media, at: Date.now() })
|
|
56
|
+
return media
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// image (Buffer) is optional; mentions = JIDs cited as @number in the body. `baileys` only exists for tests (injection).
|
|
60
|
+
async function send(sock, jid, { image, imageId, body, footer, buttons, msg, mentions }, baileys) {
|
|
61
|
+
const { generateWAMessageFromContent, prepareWAMessageMedia } = baileys || require("../core/baileys").loadBaileys()
|
|
62
|
+
let header = { title: "", subtitle: "", hasMediaAttachment: false }
|
|
63
|
+
if (image) header = { title: "", subtitle: "", hasMediaAttachment: true, ...(await prepareImage(sock, image, imageId, prepareWAMessageMedia)) }
|
|
64
|
+
|
|
65
|
+
const interactiveMessage = { header, body: { text: body }, footer: { text: footer || "" }, nativeFlowMessage: { buttons, messageParamsJson: "" } }
|
|
66
|
+
const quote = quoteOf(sock, msg)
|
|
67
|
+
if (quote || mentions?.length) interactiveMessage.contextInfo = { ...(quote || {}), ...(mentions?.length ? { mentionedJid: mentions } : {}) }
|
|
68
|
+
|
|
69
|
+
const waMsg = generateWAMessageFromContent(jid, {
|
|
70
|
+
viewOnceMessage: { message: { messageContextInfo: { deviceListMetadata: {}, deviceListMetadataVersion: 2 }, interactiveMessage } }
|
|
71
|
+
}, { userJid: sock.user?.id })
|
|
72
|
+
|
|
73
|
+
await sock.relayMessage(jid, waMsg.message, { messageId: waMsg.key.id, additionalNodes: extraNodes(jid) })
|
|
74
|
+
return waMsg
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A tap on a button / list row: returns the id WITHOUT the mark (e.g. "menu games"), or null.
|
|
78
|
+
// Only ids with this bot's mark count, and a reply quoting ANOTHER number's message (another bot in the group) is ignored.
|
|
79
|
+
function parseSelection(msg, { sock } = {}) {
|
|
80
|
+
const m = parse(msg)
|
|
81
|
+
const id = m.selection?.id
|
|
82
|
+
if (typeof id !== "string" || !id.startsWith(mark)) return null
|
|
83
|
+
const author = m.context?.participant
|
|
84
|
+
const me = [sock?.user?.id, sock?.user?.lid].filter(Boolean).map(userPart)
|
|
85
|
+
if (author && me.length && !me.includes(userPart(author))) return null
|
|
86
|
+
return id.slice(mark.length)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { mark, button, section, row, send, quoteOf, extraNodes, parseSelection }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { createInteractive }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/interactive", "version": "0.1.0", "description": "Native WhatsApp interactive messages (image + text + buttons, selection lists) and parsing of the taps. Baileys has no ready-made sendMessage for this: the message is built by hand and relayed with the 'biz' nodes WhatsApp requires.", "main": "index.js", "dependencies": { "@morpho/message": "file:../message" }, "license": "MIT" }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# morpho-socket/jid
|
|
2
|
+
|
|
3
|
+
**Identity.** Find out who really sent a message.
|
|
4
|
+
|
|
5
|
+
WhatsApp now identifies the same person in two ways: the phone-number JID (`5511…@s.whatsapp.net`) and an opaque **LID** (`123…@lid`) that is *not* the phone number. Depending on the Baileys version, the chat type and the person, a message arrives with one or the other. This module always gives you the same thing — the clean number (digits only), or `null` when it truly cannot be found — and tells you where the answer came from.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const { createIdentity } = require("morpho-socket/jid")
|
|
9
|
+
const identity = createIdentity({ store }) // store is optional; with it the LID map survives restarts
|
|
10
|
+
|
|
11
|
+
identity.install(sock, clientId) // on every (re)connection: learns mappings from socket events
|
|
12
|
+
|
|
13
|
+
const who = await identity.resolveSender(msg, sock, clientId)
|
|
14
|
+
who.number // "5511968111161" | null
|
|
15
|
+
who.formatted // "+55 (11) 96811-1161"
|
|
16
|
+
who.pn // "5511968111161@s.whatsapp.net" | null
|
|
17
|
+
who.lid // "123…@lid" | null
|
|
18
|
+
who.isBot // true when the sender is the bot's own number
|
|
19
|
+
who.source // "bot" | "message" | "alt" | "map" | "baileys" | "group" | "groups" | null
|
|
20
|
+
who.resolved // true when a number was found
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How a LID is resolved
|
|
24
|
+
|
|
25
|
+
From the cheapest source to the most expensive:
|
|
26
|
+
|
|
27
|
+
1. the pair Baileys 7 already sends in the message key (`participantAlt` / `remoteJidAlt`);
|
|
28
|
+
2. the **remembered map** (memory, plus your store when it implements `getLid`/`setLid`);
|
|
29
|
+
3. Baileys' own `signalRepository.lidMapping`, when available;
|
|
30
|
+
4. the group's participant list (each member carries both ids — the whole group is learned at once);
|
|
31
|
+
5. **warm-up**: one query for *all* the bot's groups. The map that holds in a group holds in private chats too, so anyone sharing a group with the bot gets resolved. Throttled (`warmUpIntervalMs`, 2 min) so an unresolvable LID cannot trigger one query per message.
|
|
32
|
+
|
|
33
|
+
Identity **never throws**. If resolving fails, `resolveSender` falls back to what arrived in the message — a message is never lost because of a lookup.
|
|
34
|
+
|
|
35
|
+
## API
|
|
36
|
+
|
|
37
|
+
`createIdentity({ store, warmUpIntervalMs })` — one service for a multi-client bot (one resolver per client, one shared store):
|
|
38
|
+
|
|
39
|
+
| | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `install(sock, clientId)` | start learning from `lid-mapping.update`, contacts and group events |
|
|
42
|
+
| `resolveSender(msg, sock, clientId)` | → the object above |
|
|
43
|
+
| `resolveJid(jid, { sock, clientId, group })` | → `{ number, source } \| null` |
|
|
44
|
+
| `learnGroups(clientId, groups)` | learn from groups you already fetched (no extra query) |
|
|
45
|
+
| `warmUp(sock, clientId, opts)` · `forClient(clientId)` · `fallback(msg, sock)` | lower level |
|
|
46
|
+
|
|
47
|
+
`createResolver({ store, warmUpIntervalMs })` is the single-number version (`sender`, `resolveLid`, `learn`, `learnFrom`, `learnGroup`, `warmUp`, `install`).
|
|
48
|
+
|
|
49
|
+
Utilities: `parts(jid)` · `kind(jid)` (`"pn" \| "lid" \| "group" \| "broadcast" \| "newsletter" \| "other"`) · `normalize(jid)` (drops device/agent) · `digits(value)` · `formatNumber(digits)` (Brazilian numbers get the local format, others `+<digits>`).
|
|
50
|
+
|
|
51
|
+
No dependency on Baileys: everything specific to it is read from the `sock` you pass, and each source is optional.
|