morpho-socket 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +155 -2
  2. package/docs/building-a-bot.md +402 -0
  3. package/package.json +35 -4
  4. package/packages/commands/README.md +78 -0
  5. package/packages/commands/antiflood.js +39 -0
  6. package/packages/commands/index.js +116 -0
  7. package/packages/commands/package.json +1 -0
  8. package/packages/context/README.md +100 -0
  9. package/packages/context/group.js +269 -0
  10. package/packages/context/index.js +132 -0
  11. package/packages/context/package.json +1 -0
  12. package/packages/context/participants.js +12 -0
  13. package/packages/context/send.js +127 -0
  14. package/packages/core/README.md +50 -0
  15. package/packages/core/baileys.js +25 -0
  16. package/packages/core/codec.js +17 -0
  17. package/packages/core/index.js +86 -0
  18. package/packages/core/package.json +7 -0
  19. package/packages/core/store.js +11 -0
  20. package/packages/groups/README.md +42 -0
  21. package/packages/groups/index.js +205 -0
  22. package/packages/groups/package.json +7 -0
  23. package/packages/interactive/README.md +41 -0
  24. package/packages/interactive/index.js +92 -0
  25. package/packages/interactive/package.json +1 -0
  26. package/packages/jid/README.md +51 -0
  27. package/packages/jid/index.js +285 -0
  28. package/packages/jid/package.json +7 -0
  29. package/packages/kv/README.md +45 -0
  30. package/packages/kv/index.js +164 -0
  31. package/packages/kv/package.json +8 -0
  32. package/packages/media/README.md +70 -0
  33. package/packages/media/cache.js +116 -0
  34. package/packages/media/erros.js +45 -0
  35. package/packages/media/find.js +51 -0
  36. package/packages/media/index.js +261 -0
  37. package/packages/media/package.json +8 -0
  38. package/packages/message/README.md +53 -0
  39. package/packages/message/index.js +317 -0
  40. package/packages/message/package.json +7 -0
  41. package/packages/pool/README.md +75 -0
  42. package/packages/pool/index.js +369 -0
  43. package/packages/pool/package.json +8 -0
  44. package/packages/presence/README.md +40 -0
  45. package/packages/presence/index.js +80 -0
  46. package/packages/presence/package.json +1 -0
  47. package/packages/reply/README.md +36 -0
  48. package/packages/reply/index.js +126 -0
  49. package/packages/reply/package.json +7 -0
  50. package/packages/reply/voice.js +32 -0
  51. package/packages/sent/README.md +27 -0
  52. package/packages/sent/index.js +65 -0
  53. package/packages/sent/package.json +1 -0
  54. package/packages/store-file/README.md +28 -0
  55. package/packages/store-file/index.js +63 -0
  56. package/packages/store-file/package.json +8 -0
  57. package/packages/store-sqlite/README.md +25 -0
  58. package/packages/store-sqlite/index.js +98 -0
  59. package/packages/store-sqlite/package.json +9 -0
@@ -0,0 +1,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.