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,369 @@
|
|
|
1
|
+
// @morpho/pool — camada 2 do Morpho.
|
|
2
|
+
//
|
|
3
|
+
// A camada 1 (@morpho/core + @morpho/store-*) resolve ONDE a sessão fica guardada.
|
|
4
|
+
// Esta camada resolve COMO GERIR várias sessões no mesmo processo: conectar, pedir código de
|
|
5
|
+
// pareamento quando não há sessão salva, reconectar sozinho quando a conexão cai (com backoff,
|
|
6
|
+
// pra não martelar o WhatsApp), parar, resetar. É a parte genérica do sessoes.js de um bot
|
|
7
|
+
// multi-cliente — SEM nenhuma regra de negócio (plano, limite de grupo, quem é dono) misturada.
|
|
8
|
+
// Quem usa pluga a própria regra por fora, pelos eventos.
|
|
9
|
+
//
|
|
10
|
+
// const pool = new MorphoPool({ store, initCreds: require("@whiskeysockets/baileys").initAuthCreds })
|
|
11
|
+
// pool.on("pairing-code", (sessionId, codigo) => console.log(sessionId, "->", codigo))
|
|
12
|
+
// pool.on("connect", (sessionId, sock) => { ...sua regra de negócio... })
|
|
13
|
+
// pool.on("disconnect", (sessionId, info) => { ... })
|
|
14
|
+
// pool.on("logged-out", (sessionId) => { ... }) // terminal: não tenta mais sozinho
|
|
15
|
+
// await pool.start("cliente-a", { numero: "5511999999999" })
|
|
16
|
+
//
|
|
17
|
+
// Mais:
|
|
18
|
+
// pool.on("socket", (sessionId, sock, { ouvir }) => ouvir("messages.upsert", ...))
|
|
19
|
+
// socket novo a CADA (re)conexão. `ouvir` é o sock.ev.on com duas proteções: ignora evento de socket antigo (já
|
|
20
|
+
// trocado por uma reconexão) e erro dentro do ouvinte vira evento "error" em vez de derrubar o processo.
|
|
21
|
+
// pool.on("replaced" | "forbidden" | "gave-up" | "stopped", ...) quedas que NÃO reconectam (ver POLITICA_PADRAO)
|
|
22
|
+
// await pool.startAll(["a", "b", { sessionId: "c", numero: "55..." }], { intervaloMs: 1500 })
|
|
23
|
+
// pool.info("a") -> { status, tentativas, pareada, motivoParada }
|
|
24
|
+
|
|
25
|
+
const EventEmitter = require("events")
|
|
26
|
+
const { useMorphoAuthState } = require("../core")
|
|
27
|
+
|
|
28
|
+
const STATUS = { CONNECTING: "connecting", OPEN: "open", RECONNECTING: "reconnecting", STOPPED: "stopped" }
|
|
29
|
+
|
|
30
|
+
const withTimeout = (promise, ms) => new Promise((resolve, reject) => {
|
|
31
|
+
const t = setTimeout(() => reject(new Error(`timeout de ${ms}ms`)), ms)
|
|
32
|
+
promise.then(v => { clearTimeout(t); resolve(v) }, e => { clearTimeout(t); reject(e) })
|
|
33
|
+
})
|
|
34
|
+
const VERSION_TTL_MS = 60 * 60 * 1000
|
|
35
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
36
|
+
|
|
37
|
+
// Por que o WhatsApp fechou a conexão (DisconnectReason do Baileys, sem acoplar ao enum aqui).
|
|
38
|
+
const CLOSE_REASONS = {
|
|
39
|
+
401: "logged-out", // saiu do aparelho / sessão revogada
|
|
40
|
+
403: "forbidden", // número restringido ou banido
|
|
41
|
+
408: "timeout",
|
|
42
|
+
411: "multidevice",
|
|
43
|
+
428: "connection-closed",
|
|
44
|
+
440: "replaced", // OUTRA instância abriu a mesma sessão (reconectar só inicia uma briga entre as duas)
|
|
45
|
+
500: "bad-session",
|
|
46
|
+
503: "unavailable",
|
|
47
|
+
515: "restart-required" // normal logo depois de parear: o WhatsApp pede um socket novo
|
|
48
|
+
}
|
|
49
|
+
// O que fazer em cada código. "reconectar" = com backoff (o padrão para tudo que não está aqui);
|
|
50
|
+
// "imediato" = já, sem gastar o backoff (no máx. MAX_IMEDIATOS seguidas); "parar" = terminal.
|
|
51
|
+
const DEFAULT_POLICY = { 401: "stop", 403: "stop", 440: "stop", 515: "immediate" }
|
|
52
|
+
const TERMINAL_EVENT = { 401: "logged-out", 403: "forbidden", 440: "replaced" }
|
|
53
|
+
const MAX_IMMEDIATE = 3
|
|
54
|
+
|
|
55
|
+
class MorphoPool extends EventEmitter {
|
|
56
|
+
// maxTentativasSemParear: número que NUNCA foi pareado (ninguém digitou o código) para de tentar depois de N quedas
|
|
57
|
+
// seguidas e emite "gave-up" (null = tenta para sempre, o comportamento antigo).
|
|
58
|
+
// politica: { [codigo]: "reconectar" | "imediato" | "parar" } sobre a POLITICA_PADRAO. O 401 sempre para.
|
|
59
|
+
constructor({ store, initCreds, makeSocket, socketConfig, backoff, versionTimeoutMs = 5000, skipNetworkVersion = false, maxAttemptsWithoutPairing = null, policy = {} } = {}) {
|
|
60
|
+
super()
|
|
61
|
+
if (!store || typeof store.readCreds !== "function")
|
|
62
|
+
throw new Error("MorphoPool: 'store' é obrigatório (um MorphoAuthStore, ver @morpho/core).")
|
|
63
|
+
if (typeof initCreds !== "function")
|
|
64
|
+
throw new Error("MorphoPool: 'initCreds' é obrigatório (ex.: require('@whiskeysockets/baileys').initAuthCreds).")
|
|
65
|
+
|
|
66
|
+
this.store = store
|
|
67
|
+
this.initCreds = initCreds
|
|
68
|
+
this.maxAttemptsWithoutPairing = maxAttemptsWithoutPairing
|
|
69
|
+
this.policy = { ...DEFAULT_POLICY, ...policy, 401: "stop" }
|
|
70
|
+
// socketConfig(sessionId, state) -> extra makeWASocket options (getMessage, cachedGroupMetadata, browser...), merged into the default factory
|
|
71
|
+
this.socketConfig = socketConfig
|
|
72
|
+
this.makeSocket = makeSocket || this._defaultFactory.bind(this)
|
|
73
|
+
// backoff(tentativa) -> ms de espera. Padrão: exponencial, 2s/4s/8s/16s/32s, teto de 60s.
|
|
74
|
+
this.backoff = backoff || (attempt => Math.min(60000, 2000 * 2 ** attempt))
|
|
75
|
+
this._sessions = new Map() // sessionId -> { sock, status, tentativas, timer, opts }
|
|
76
|
+
this.versionTimeoutMs = versionTimeoutMs
|
|
77
|
+
// DIAGNÓSTICO: desde fev/2026 o WhatsApp vem rejeitando pareamento com versão de protocolo
|
|
78
|
+
// desatualizada, e fetchLatestBaileysVersion() às vezes devolve uma versão que já não serve
|
|
79
|
+
// mais, sem erro nenhum (não é falha de rede, é resposta "válida" porém velha). Com isso em
|
|
80
|
+
// true, nem tenta buscar: usa a versão que já vem empacotada dentro do próprio Baileys.
|
|
81
|
+
this.skipNetworkVersion = skipNetworkVersion
|
|
82
|
+
this._version = null // última versão do protocolo obtida com sucesso (cache, ver _versaoBaileys)
|
|
83
|
+
this._versionAt = 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- fábrica de socket padrão, usando o Baileys de verdade. Quem testa (ou quem quiser outra
|
|
87
|
+
// configuração de makeWASocket) pode passar um "makeSocket" próprio no construtor.
|
|
88
|
+
async _defaultFactory({ sessionId, state }) {
|
|
89
|
+
const baileys = require("../core/baileys").loadBaileys()
|
|
90
|
+
const { fetchLatestBaileysVersion, Browsers } = baileys
|
|
91
|
+
// Baileys 6 exporta makeWASocket nomeado; o 7 (ESM) pode entregá-lo só como default
|
|
92
|
+
const makeWASocket = baileys.makeWASocket ?? baileys.default?.makeWASocket ?? baileys.default
|
|
93
|
+
let version
|
|
94
|
+
if (this.skipNetworkVersion) console.warn("[morpho/pool] semVersaoDaRede ligado: usando a versão de protocolo empacotada no Baileys, sem consultar a rede.")
|
|
95
|
+
else version = await this._baileysVersion(fetchLatestBaileysVersion)
|
|
96
|
+
|
|
97
|
+
// "Nome (SO)" — vários relatos recentes apontam Browsers.ubuntu(...) como instável
|
|
98
|
+
// especificamente no fluxo de CÓDIGO DE PAREAMENTO (QR é mais tolerante a isso)
|
|
99
|
+
const config = { auth: state, logger: require("../core/baileys").silentLogger(), printQRInTerminal: false, browser: Browsers.macOS("Chrome") }
|
|
100
|
+
Object.assign(config, await this.socketConfig?.(sessionId, state))
|
|
101
|
+
if (version) config.version = version // sem versão: o Baileys usa a embutida
|
|
102
|
+
return makeWASocket(config)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A versão do protocolo vem de uma consulta de rede, e a reconexão acontece justamente quando a rede pode
|
|
106
|
+
// estar fora: a consulta não pode travar (timeout) e não precisa repetir a cada tentativa (cache de 1h; se a
|
|
107
|
+
// rede cair depois do cache vencer, vale a última versão boa). Sem versão nenhuma, o Baileys usa a embutida
|
|
108
|
+
// (que pode estar desatualizada em relação ao que o WhatsApp aceita hoje), então avisamos em vez de seguir calado.
|
|
109
|
+
async _baileysVersion(fetchLatestBaileysVersion) {
|
|
110
|
+
if (this._version && Date.now() - this._versionAt < VERSION_TTL_MS) return this._version
|
|
111
|
+
try {
|
|
112
|
+
const r = await withTimeout(Promise.resolve(fetchLatestBaileysVersion()), this.versionTimeoutMs)
|
|
113
|
+
if (r?.version && r.isLatest !== false) { this._version = r.version; this._versionAt = Date.now(); return r.version }
|
|
114
|
+
// sem rede o Baileys não lança erro: devolve a versão embutida com isLatest:false
|
|
115
|
+
this._warnVersion(r?.error?.message || "o servidor de versões não respondeu")
|
|
116
|
+
return this._version || r?.version
|
|
117
|
+
} catch (e) {
|
|
118
|
+
this._warnVersion(e.message)
|
|
119
|
+
return this._version || undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_warnVersion(reason) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`[morpho/pool] não consegui buscar a versão mais recente do protocolo (sem internet aqui?); usando ${this._version ? "a última versão obtida" : "a versão empacotada no Baileys, que pode estar desatualizada"}:`,
|
|
126
|
+
reason
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- API pública ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// Conecta (ou reaproveita, se já estiver rodando). opts.numero só é usado se ainda não houver
|
|
133
|
+
// sessão salva (pareamento). Devolve uma promise que resolve quando a conexão abre PELA PRIMEIRA
|
|
134
|
+
// VEZ nesta chamada — reconexões automáticas depois disso são só eventos, não bloqueiam ninguém.
|
|
135
|
+
async start(sessionId, opts = {}) {
|
|
136
|
+
const current = this._sessions.get(sessionId)
|
|
137
|
+
if (current && current.status !== STATUS.STOPPED) return current.sock
|
|
138
|
+
|
|
139
|
+
const entry = { sock: null, status: STATUS.CONNECTING, attempts: 0, immediates: 0, timer: null, opts, state: null, stopReason: null }
|
|
140
|
+
this._sessions.set(sessionId, entry)
|
|
141
|
+
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
entry._resolveFirstConnection = resolve
|
|
144
|
+
entry._rejectFirstConnection = reject
|
|
145
|
+
this._connect(sessionId).catch(e => this._fatalFailure(sessionId, e))
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sobe várias sessões com um intervalo entre elas (todas ao mesmo tempo no boot é o que o WhatsApp mais estranha).
|
|
150
|
+
// Não espera abrir: uma sessão nova pode ficar minutos esperando alguém digitar o código. Quem avisa é o evento
|
|
151
|
+
// "connect"; falha vira evento "error". Itens: "id" ou { sessionId, numero } ou { sessionId, opts }.
|
|
152
|
+
async startAll(items, { intervalMs = 1500 } = {}) {
|
|
153
|
+
const list = [...items]
|
|
154
|
+
const started = []
|
|
155
|
+
for (let i = 0; i < list.length; i++) {
|
|
156
|
+
const item = list[i]
|
|
157
|
+
const sessionId = typeof item === "string" ? item : item.sessionId
|
|
158
|
+
const opts = typeof item === "string" ? {} : (item.opts || (item.phoneNumber ? { phoneNumber: item.phoneNumber } : {}))
|
|
159
|
+
this.start(sessionId, opts).catch(() => { /* já emitido como "error" */ })
|
|
160
|
+
started.push(sessionId)
|
|
161
|
+
if (i < list.length - 1 && intervalMs > 0) await sleep(intervalMs)
|
|
162
|
+
}
|
|
163
|
+
return started
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Retrato de uma sessão (para painel).
|
|
167
|
+
info(sessionId) {
|
|
168
|
+
const e = this._sessions.get(sessionId)
|
|
169
|
+
if (!e) return { sessionId, status: STATUS.STOPPED, attempts: 0, paired: false, stopReason: null }
|
|
170
|
+
return { sessionId, status: e.status, attempts: e.attempts, paired: this._isPaired(e), stopReason: e.stopReason || null }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Encerra a sessão sem apagar as credenciais: dá pra chamar start() de novo depois, sem parear.
|
|
174
|
+
stop(sessionId) {
|
|
175
|
+
const entry = this._sessions.get(sessionId)
|
|
176
|
+
if (!entry) return
|
|
177
|
+
entry.status = STATUS.STOPPED
|
|
178
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
179
|
+
entry.timer = null
|
|
180
|
+
try { entry.sock?.end?.(new Error("parado via MorphoPool.stop()")) } catch { /* já pode estar fechado */ }
|
|
181
|
+
entry.sock = null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Encerra E apaga a sessão salva: a próxima start() vai pedir pareamento de novo.
|
|
185
|
+
async reset(sessionId) {
|
|
186
|
+
this.stop(sessionId)
|
|
187
|
+
this._sessions.delete(sessionId)
|
|
188
|
+
await this.store.deleteSession(sessionId)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get(sessionId) { return this._sessions.get(sessionId)?.sock ?? null }
|
|
192
|
+
|
|
193
|
+
status(sessionId) { return this._sessions.get(sessionId)?.status ?? STATUS.STOPPED }
|
|
194
|
+
|
|
195
|
+
list() { return [...this._sessions.entries()].map(([sessionId, e]) => ({ sessionId, status: e.status })) }
|
|
196
|
+
|
|
197
|
+
// --- interno ---------------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
// `reconexao` = foi o timer do backoff que chamou (e não o start()). A diferença importa no erro: falhar na
|
|
200
|
+
// 1ª criação rejeita o start() (quem chamou precisa saber); falhar numa reconexão não pode matar a sessão.
|
|
201
|
+
async _connect(sessionId, isReconnect = false) {
|
|
202
|
+
const entry = this._sessions.get(sessionId)
|
|
203
|
+
if (!entry || entry.status === STATUS.STOPPED) return // start() seguido de stop() antes de terminar
|
|
204
|
+
|
|
205
|
+
// stop()/reset()/novo start() podem acontecer enquanto esperamos um await abaixo: sempre rechecar depois
|
|
206
|
+
const alive = () => this._sessions.get(sessionId) === entry && entry.status !== STATUS.STOPPED
|
|
207
|
+
|
|
208
|
+
let state, saveCreds
|
|
209
|
+
try {
|
|
210
|
+
await this._discardIncompletePairing(sessionId)
|
|
211
|
+
const auth = await useMorphoAuthState(this.store, sessionId, { initCreds: this.initCreds })
|
|
212
|
+
state = auth.state; saveCreds = auth.saveCreds
|
|
213
|
+
} catch (e) { return this._creationFailure(sessionId, entry, e, isReconnect) }
|
|
214
|
+
if (!alive()) return
|
|
215
|
+
|
|
216
|
+
let sock
|
|
217
|
+
try { sock = await this.makeSocket({ sessionId, state, saveCreds }) }
|
|
218
|
+
catch (e) { return this._creationFailure(sessionId, entry, e, isReconnect) }
|
|
219
|
+
if (!alive()) { // pararam durante a criação: este socket nasceu sem dono, não pode ficar conectado
|
|
220
|
+
try { sock?.end?.(new Error("sessão parada durante a criação do socket")) } catch { /* já fechado */ }
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
entry.sock = sock
|
|
225
|
+
entry.state = state
|
|
226
|
+
this._announceSocket(sessionId, entry, sock)
|
|
227
|
+
// falha ao salvar não pode virar rejeição não tratada (derruba o Node): vira evento "error"
|
|
228
|
+
sock.ev.on("creds.update", () => {
|
|
229
|
+
try { Promise.resolve(saveCreds()).catch(e => this._emitError(sessionId, e)) }
|
|
230
|
+
catch (e) { this._emitError(sessionId, e) }
|
|
231
|
+
})
|
|
232
|
+
// o ouvinte de conexão precisa existir ANTES de qualquer await abaixo: se a conexão cair enquanto o código
|
|
233
|
+
// de pareamento é pedido, esse evento não pode se perder (a sessão ficaria sem reconexão)
|
|
234
|
+
// O código de pareamento só pode ser pedido DEPOIS que o WhatsApp responde ao handshake, o que ele sinaliza
|
|
235
|
+
// mandando o evento "qr". Pedir logo após makeWASocket() dá "Connection Closed" (o socket ainda nem abriu) E
|
|
236
|
+
// pior: o requestPairingCode grava creds.me ANTES de enviar; o handshake então vê "me" preenchido, acha que
|
|
237
|
+
// este device já existe, manda LOGIN em vez de REGISTRO e o servidor responde 401 "Connection Failure".
|
|
238
|
+
// O Morvyn já faz assim (pedirCodigo dentro do connection.update, quando u.qr chega).
|
|
239
|
+
let requestedCode = false
|
|
240
|
+
sock.ev.on("connection.update", update => {
|
|
241
|
+
this._onConnectionUpdate(sessionId, sock, update)
|
|
242
|
+
if (update.qr && !requestedCode && !state.creds.registered && entry.opts.phoneNumber) {
|
|
243
|
+
requestedCode = true
|
|
244
|
+
this._requestCode(sessionId, entry, sock, entry.opts.phoneNumber, alive)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async _requestCode(sessionId, entry, sock, phoneNumber, alive) {
|
|
250
|
+
try {
|
|
251
|
+
const code = await sock.requestPairingCode(String(phoneNumber).replace(/\D/g, ""))
|
|
252
|
+
if (alive() && entry.sock === sock) this.emit("pairing-code", sessionId, code)
|
|
253
|
+
} catch (e) { this._emitError(sessionId, e) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Credencial que existe mas nunca completou o pareamento (registered:false e sem "account", que só aparece no
|
|
257
|
+
// pair-success) é lixo de uma tentativa que falhou: ela já tem creds.me (gravado pelo requestPairingCode), e
|
|
258
|
+
// reaproveitá-la faz o próximo socket mandar LOGIN de um device que não existe → 401 de novo, pra sempre.
|
|
259
|
+
// Descartar aqui evita ter que "apagar o sqlite na mão". Sessão pareada de verdade (registered ou account) nunca é tocada.
|
|
260
|
+
async _discardIncompletePairing(sessionId) {
|
|
261
|
+
const creds = await this.store.readCreds(sessionId)
|
|
262
|
+
if (creds && !creds.registered && !creds.account) await this.store.deleteSession(sessionId)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_onConnectionUpdate(sessionId, sock, update) {
|
|
266
|
+
const entry = this._sessions.get(sessionId)
|
|
267
|
+
// parada, ou evento de um socket antigo (já trocado por uma reconexão): não é da conta da sessão atual
|
|
268
|
+
if (!entry || entry.status === STATUS.STOPPED || entry.sock !== sock) return
|
|
269
|
+
|
|
270
|
+
if (update.connection === "open") {
|
|
271
|
+
entry.status = STATUS.OPEN
|
|
272
|
+
entry.attempts = 0 // sucesso zera o backoff
|
|
273
|
+
entry.immediates = 0
|
|
274
|
+
this.emit("connect", sessionId, entry.sock)
|
|
275
|
+
entry._resolveFirstConnection?.(entry.sock)
|
|
276
|
+
delete entry._resolveFirstConnection
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (update.connection === "close") {
|
|
280
|
+
const code = update.lastDisconnect?.error?.output?.statusCode
|
|
281
|
+
const message = update.lastDisconnect?.error?.message
|
|
282
|
+
const loggedOut = code === 401 // DisconnectReason.loggedOut
|
|
283
|
+
const reason = CLOSE_REASONS[code] || (code ? "unknown" : "dropped")
|
|
284
|
+
const action = this.policy[code] || "reconnect"
|
|
285
|
+
entry.sock = null // esse socket morreu: eventos tardios dele caem na checagem acima, e get() não entrega socket morto
|
|
286
|
+
this.emit("disconnect", sessionId, { code, message, loggedOut, reason, action })
|
|
287
|
+
|
|
288
|
+
if (action === "stop") {
|
|
289
|
+
const label = loggedOut ? `Sessão deslogada durante o pareamento (código ${code}: ${message})` : `Sessão parada pelo WhatsApp (${reason}, código ${code}: ${message})`
|
|
290
|
+
return this._halt(sessionId, entry, { code, message, reason }, TERMINAL_EVENT[code], new Error(label))
|
|
291
|
+
}
|
|
292
|
+
if (action === "immediate" && entry.immediates < MAX_IMMEDIATE) {
|
|
293
|
+
entry.immediates++
|
|
294
|
+
return this._scheduleReconnect(sessionId, entry, { immediate: true, reason })
|
|
295
|
+
}
|
|
296
|
+
this._scheduleReconnect(sessionId, entry, { reason })
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Parada terminal (logout, substituída, proibida, desistência): não reconecta sozinho. start() de novo recomeça.
|
|
301
|
+
_halt(sessionId, entry, info, event, error) {
|
|
302
|
+
entry.status = STATUS.STOPPED
|
|
303
|
+
entry.stopReason = info.reason
|
|
304
|
+
if (entry.timer) { clearTimeout(entry.timer); entry.timer = null }
|
|
305
|
+
if (event) this.emit(event, sessionId, info)
|
|
306
|
+
this.emit("stopped", sessionId, info)
|
|
307
|
+
entry._rejectFirstConnection?.(error)
|
|
308
|
+
delete entry._rejectFirstConnection
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Sessão que já completou o pareamento (registered, ou account, que só aparece no pair-success).
|
|
312
|
+
_isPaired(entry) {
|
|
313
|
+
const c = entry.state?.creds
|
|
314
|
+
return !!(c?.registered || c?.account)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Avisa quem quer ligar ouvintes no socket novo. O `ouvir` ignora evento de socket já trocado e não deixa erro de
|
|
318
|
+
// ouvinte virar rejeição não tratada.
|
|
319
|
+
_announceSocket(sessionId, entry, sock) {
|
|
320
|
+
if (this.listenerCount("socket") === 0) return
|
|
321
|
+
const listen = (event, fn) => sock.ev.on(event, (...args) => {
|
|
322
|
+
if (entry.sock !== sock || entry.status === STATUS.STOPPED) return
|
|
323
|
+
try { Promise.resolve(fn(...args)).catch(e => this._emitError(sessionId, e)) }
|
|
324
|
+
catch (e) { this._emitError(sessionId, e) }
|
|
325
|
+
})
|
|
326
|
+
try { this.emit("socket", sessionId, sock, { listen }) }
|
|
327
|
+
catch (e) { this._emitError(sessionId, e) }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_scheduleReconnect(sessionId, entry, { immediate = false, reason } = {}) {
|
|
331
|
+
if (entry.timer) return // já há uma reconexão agendada: nunca duas
|
|
332
|
+
// número que ninguém parou para parear: depois de N quedas seguidas, desiste em vez de recriar o pareamento para sempre
|
|
333
|
+
const max = this.maxAttemptsWithoutPairing
|
|
334
|
+
if (!immediate && max && !this._isPaired(entry) && entry.attempts >= max)
|
|
335
|
+
return this._halt(sessionId, entry, { reason: "gave-up", attempts: entry.attempts }, "gave-up",
|
|
336
|
+
new Error(`Desisti de conectar "${sessionId}" depois de ${entry.attempts} tentativas sem parear`))
|
|
337
|
+
entry.status = STATUS.RECONNECTING
|
|
338
|
+
const delay = immediate ? 0 : this.backoff(entry.attempts++) // reconexão imediata não gasta o backoff
|
|
339
|
+
this.emit("reconnecting", sessionId, { attempt: entry.attempts, delayMs: delay, reason, immediate })
|
|
340
|
+
entry.timer = setTimeout(() => {
|
|
341
|
+
entry.timer = null
|
|
342
|
+
this._connect(sessionId, true).catch(e => this._emitError(sessionId, e))
|
|
343
|
+
}, delay)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_creationFailure(sessionId, entry, error, isReconnect) {
|
|
347
|
+
if (!isReconnect) return this._fatalFailure(sessionId, error) // 1ª tentativa: start() rejeita, como antes
|
|
348
|
+
// reconectando (ex.: sem rede na hora de criar o socket): não morre; avisa e tenta de novo com backoff
|
|
349
|
+
if (this._sessions.get(sessionId) !== entry || entry.status === STATUS.STOPPED) return
|
|
350
|
+
this._emitError(sessionId, error)
|
|
351
|
+
this._scheduleReconnect(sessionId, entry)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_fatalFailure(sessionId, error) {
|
|
355
|
+
const entry = this._sessions.get(sessionId)
|
|
356
|
+
if (entry) entry.status = STATUS.STOPPED
|
|
357
|
+
try { this._emitError(sessionId, error) }
|
|
358
|
+
finally { entry?._rejectFirstConnection?.(error) } // mesmo que um ouvinte lance, o start() não fica pendurado
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// EventEmitter lança se alguém emite "error" sem ouvinte (e ainda lançaria o 1º argumento, o sessionId):
|
|
362
|
+
// sem ouvinte, o erro vai para o console em vez de derrubar o processo.
|
|
363
|
+
_emitError(sessionId, error) {
|
|
364
|
+
if (this.listenerCount("error") > 0) this.emit("error", sessionId, error)
|
|
365
|
+
else console.error(`[morpho/pool] erro na sessão "${sessionId}" (ninguém ouvindo o evento "error"):`, error)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = { MorphoPool, STATUS, CLOSE_REASONS, DEFAULT_POLICY }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morpho/pool",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gerente de várias sessões de WhatsApp no mesmo processo: conectar, reconectar com backoff, pareamento, parar, resetar. Sem nenhuma regra de negócio (planos, limites) — isso fica por conta de quem usa.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"dependencies": { "@morpho/core": "file:../core" },
|
|
7
|
+
"license": "MIT"
|
|
8
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# morpho-socket/presence
|
|
2
|
+
|
|
3
|
+
Two small things that make a bot feel natural.
|
|
4
|
+
|
|
5
|
+
## 1. "typing…" / "recording audio…" while the bot works
|
|
6
|
+
|
|
7
|
+
WhatsApp clears the indicator after a few seconds, so it is renewed until the work ends (then `paused` is sent). If the work finishes quickly, nothing shows.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
const { createPresence } = require("morpho-socket/presence")
|
|
11
|
+
const presence = createPresence({ enabled: () => true }) // intervalMs defaults to 7000
|
|
12
|
+
|
|
13
|
+
await presence.during(sock, jid, "composing", async () => {
|
|
14
|
+
return await slowThing()
|
|
15
|
+
}, { delayMs: 1200 }) // only show the indicator if the work takes > 1.2 s
|
|
16
|
+
|
|
17
|
+
const stop = presence.start(sock, jid, "recording") // manual form, for when try/finally is easier
|
|
18
|
+
// … later
|
|
19
|
+
stop()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
States: `"composing"` (typing) · `"recording"`. `enabled` is read on every call, so you can toggle it at runtime.
|
|
23
|
+
|
|
24
|
+
## 2. A minimum response delay
|
|
25
|
+
|
|
26
|
+
The bot only answers after a minimum time **counted from when the message arrived**, not from when the command finished. Works for every command without editing any of them.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const { createResponseDelay } = require("morpho-socket/presence")
|
|
30
|
+
const delay = createResponseDelay({ delayMs: () => 2000, showTyping: () => true })
|
|
31
|
+
|
|
32
|
+
delay.install(sock) // wraps sendMessage / relayMessage, once per socket
|
|
33
|
+
|
|
34
|
+
await delay.run(() => handleMessage(msg)) // marks "arrived now" for everything sent inside
|
|
35
|
+
await delay.run(() => handleBatch(m), arrivedAt) // a batch shares ONE arrival time: the delay doesn't add up
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Edits, reactions, deletes and broadcast chats are never delayed. With `showTyping`, the indicator is shown while waiting (`recording` for audio).
|
|
39
|
+
|
|
40
|
+
Both factories read their settings through functions, so a dashboard can change them without restarting the bot.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @morpho/presence
|
|
2
|
+
//
|
|
3
|
+
// 1) createPresence: "typing…" / "recording audio…" while the bot works. WhatsApp clears the indicator after a few
|
|
4
|
+
// seconds, so it is renewed until the work ends (then "paused" is sent). If the work ends fast, nothing shows.
|
|
5
|
+
// 2) createResponseDelay: the bot only answers after a minimum time counted from when the message ARRIVED. Works for any
|
|
6
|
+
// command without editing it (AsyncLocalStorage carries the arrival time).
|
|
7
|
+
const { AsyncLocalStorage } = require("async_hooks")
|
|
8
|
+
|
|
9
|
+
const INTERVAL_MS = 7000
|
|
10
|
+
|
|
11
|
+
// opts: { enabled: () => boolean (default true), intervalMs }
|
|
12
|
+
function createPresence({ enabled = () => true, intervalMs = INTERVAL_MS } = {}) {
|
|
13
|
+
function send(sock, jid, state) {
|
|
14
|
+
try { Promise.resolve(sock?.sendPresenceUpdate?.(state, jid)).catch(() => {}) } catch { /* presence is decoration */ }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// during(sock, jid, "composing" | "recording", fn, { delayMs }): runs fn with the indicator on (only after delayMs)
|
|
18
|
+
async function during(sock, jid, state, fn, { delayMs = 0 } = {}) {
|
|
19
|
+
let on = false, wait = null, cycle = null
|
|
20
|
+
const turnOn = () => { on = true; send(sock, jid, state); cycle = setInterval(() => send(sock, jid, state), intervalMs); cycle.unref?.() }
|
|
21
|
+
if (state && enabled()) {
|
|
22
|
+
if (delayMs > 0) { wait = setTimeout(turnOn, delayMs); wait.unref?.() } else turnOn()
|
|
23
|
+
}
|
|
24
|
+
try { return await fn() }
|
|
25
|
+
finally { clearTimeout(wait); clearInterval(cycle); if (on) send(sock, jid, "paused") }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// turns the indicator on and returns the function that turns it off (for spots where try/finally is easier)
|
|
29
|
+
function start(sock, jid, state) {
|
|
30
|
+
if (!state || !enabled()) return () => {}
|
|
31
|
+
send(sock, jid, state)
|
|
32
|
+
const cycle = setInterval(() => send(sock, jid, state), intervalMs)
|
|
33
|
+
cycle.unref?.()
|
|
34
|
+
let stopped = false
|
|
35
|
+
return () => { if (stopped) return; stopped = true; clearInterval(cycle); send(sock, jid, "paused") }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { during, start, send }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// opts: { delayMs: () => number, showTyping: () => boolean } (read on every send, so they can change at runtime)
|
|
42
|
+
function createResponseDelay({ delayMs = () => 0, showTyping = () => false } = {}) {
|
|
43
|
+
const als = new AsyncLocalStorage()
|
|
44
|
+
|
|
45
|
+
// runs fn marking "the message arrived now" (or at `arrivedAt`) for everything fn sends.
|
|
46
|
+
// A batch passes the SAME arrivedAt to all messages: the delay doesn't add up message by message.
|
|
47
|
+
const run = (fn, arrivedAt = Date.now()) => als.run({ arrivedAt }, fn)
|
|
48
|
+
|
|
49
|
+
async function wait(sock, jid, content) {
|
|
50
|
+
if (content?.edit) return // editing a message that already left doesn't wait
|
|
51
|
+
const ms = Number(delayMs()) || 0
|
|
52
|
+
const store = als.getStore()
|
|
53
|
+
if (!ms || !store || String(jid).includes("broadcast")) return
|
|
54
|
+
const missing = store.arrivedAt + ms - Date.now()
|
|
55
|
+
if (missing <= 0) return
|
|
56
|
+
const isMessage = content && !content.react && !content.delete && !content.protocolMessage
|
|
57
|
+
if (showTyping() && isMessage) {
|
|
58
|
+
try { Promise.resolve(sock.sendPresenceUpdate?.(content.audio ? "recording" : "composing", jid)).catch(() => {}) } catch { /* decoration */ }
|
|
59
|
+
}
|
|
60
|
+
await new Promise(r => setTimeout(r, missing))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// wraps sendMessage/relayMessage of the socket (once per socket)
|
|
64
|
+
function install(sock) {
|
|
65
|
+
if (sock.__morphoDelay) return sock
|
|
66
|
+
sock.__morphoDelay = true
|
|
67
|
+
const send = sock.sendMessage.bind(sock)
|
|
68
|
+
sock.sendMessage = async (jid, content, opts) => { await wait(sock, jid, content); return send(jid, content, opts) }
|
|
69
|
+
if (typeof sock.relayMessage === "function") {
|
|
70
|
+
const relay = sock.relayMessage.bind(sock)
|
|
71
|
+
// interactive menus use relayMessage directly; Baileys' sendMessage calls the internal relay, so it isn't counted twice
|
|
72
|
+
sock.relayMessage = async (jid, msg, opts) => { await wait(sock, jid, msg); return relay(jid, msg, opts) }
|
|
73
|
+
}
|
|
74
|
+
return sock
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { run, install, wait }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { createPresence, createResponseDelay, INTERVAL_MS }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/presence", "version": "0.1.0", "description": "'typing...' / 'recording audio...' while the bot works (renewed until done) and a minimum response delay measured from when the message ARRIVED.", "main": "index.js", "license": "MIT" }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# morpho-socket/reply
|
|
2
|
+
|
|
3
|
+
Reply on WhatsApp **quoting the original message without losing its mentions**, plus the small "send something as a reply" helpers every bot rewrites.
|
|
4
|
+
|
|
5
|
+
**The problem:** when Baileys quotes a message that mentions someone, it drops the original `contextInfo`, so the quoted bubble shows a raw `@lid` / number instead of the person's name. Morpho builds the quote by hand and carries the mentions along.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const reply = require("morpho-socket/reply")
|
|
9
|
+
|
|
10
|
+
await reply.reply(sock, jid, msg, "hello") // quotes msg, keeps its mentions
|
|
11
|
+
await reply.replyImage(sock, jid, msg, buffer, { caption: "look" })
|
|
12
|
+
await reply.replyGif(sock, jid, msg, videoBuffer, { caption, mentions })
|
|
13
|
+
await reply.sendVoice(sock, jid, mp3Buffer, { quoted: msg }) // converts to ogg/opus (needs ffmpeg)
|
|
14
|
+
await reply.sendSticker(sock, jid, webpBuffer, { quoted: msg })
|
|
15
|
+
await reply.react(sock, msg, "🎉")
|
|
16
|
+
await reply.sendPoll(sock, jid, "Pizza?", ["yes", "no"])
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`reply()` also notices when your text repeats the `@number` of someone the original message mentioned, and turns it into a real mention: if someone sends `!chance of @ana becoming admin` and the bot answers `@ana has 42%…`, WhatsApp shows her name (clickable) instead of the number.
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
| | |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `reply(sock, jid, msg, text, extra?)` | text reply; keeps and extends mentions |
|
|
26
|
+
| `replyWithMentions(sock, jid, msg, text, mentions, extra?)` | explicit mentions |
|
|
27
|
+
| `sendQuoted(sock, jid, msg, content, { always }?)` | **any** content (text, image…) quoting `msg`; the manual quote is used only when the original mentions someone (or with `always`) |
|
|
28
|
+
| `replyImage` / `replyGif` / `replyMedia` | media quoting the original. If sending fails (corrupt file, too large) it falls back to a text reply instead of staying silent (`onError` is called) |
|
|
29
|
+
| `replySticker(sock, jid, msg, webp)` | sticker, always quoted |
|
|
30
|
+
| `sendSticker(sock, jid, webp, { quoted }?)` | sticker to any chat; quotes only if asked |
|
|
31
|
+
| `sendVoice(sock, jid, audio, { quoted, convert }?)` | voice note. WhatsApp only plays PTT as ogg/opus, so other formats are converted; pass `convert: false` if the buffer is already ogg/opus |
|
|
32
|
+
| `react(sock, msg, emoji)` · `sendPoll(sock, jid, question, options, { selectableCount }?)` | |
|
|
33
|
+
| `toOggOpus(buffer, { ffmpeg, timeoutMs }?)` | audio → ogg/opus using the `ffmpeg` binary |
|
|
34
|
+
| `buildQuoteContext(sock, msg, mentions)` · `mentionsOf(msg)` · `stripDevice(jid)` | building blocks |
|
|
35
|
+
|
|
36
|
+
> Using [`context`](../context)? `g.reply()` already does all of this — you rarely call this module directly.
|