morpho-socket 0.3.0 → 0.4.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 CHANGED
@@ -37,6 +37,10 @@ Morpho is CommonJS.
37
37
  | One object with everything about the current message | [`context`](packages/context) |
38
38
  | Commands from folders, aliases, admin/owner guards, antiflood | [`commands`](packages/commands) |
39
39
  | Bot data (economy, settings, cooldowns) with real transactions | [`kv`](packages/kv) |
40
+ | Invites, challenges, trades: a request that expires if nobody answers | [`pending`](packages/pending) |
41
+ | Relations between two people (couple, friend, parent, team…) with limits | [`links`](packages/links) |
42
+ | Do something later or on repeat (reminders, daily open/close), surviving restarts | [`scheduler`](packages/scheduler) |
43
+ | Conversations of several messages: confirmations, forms, games, reply-to-answer | [`ask`](packages/ask) |
40
44
 
41
45
  ## A quick look
42
46
 
@@ -143,7 +147,7 @@ module.exports = {
143
147
 
144
148
  - **[Building a bot with Morpho](docs/building-a-bot.md)** — a tour of every feature and how to combine them.
145
149
  - Each module has its own README with the full API:
146
- [`core`](packages/core) · [`store-sqlite`](packages/store-sqlite) · [`store-file`](packages/store-file) · [`pool`](packages/pool) · [`message`](packages/message) · [`jid`](packages/jid) · [`groups`](packages/groups) · [`media`](packages/media) · [`reply`](packages/reply) · [`interactive`](packages/interactive) · [`presence`](packages/presence) · [`sent`](packages/sent) · [`context`](packages/context) · [`commands`](packages/commands) · [`kv`](packages/kv)
150
+ [`core`](packages/core) · [`store-sqlite`](packages/store-sqlite) · [`store-file`](packages/store-file) · [`pool`](packages/pool) · [`message`](packages/message) · [`jid`](packages/jid) · [`groups`](packages/groups) · [`media`](packages/media) · [`reply`](packages/reply) · [`interactive`](packages/interactive) · [`presence`](packages/presence) · [`sent`](packages/sent) · [`context`](packages/context) · [`commands`](packages/commands) · [`kv`](packages/kv) · [`pending`](packages/pending) · [`links`](packages/links) · [`scheduler`](packages/scheduler) · [`ask`](packages/ask)
147
151
 
148
152
  ## Testing
149
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morpho-socket",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Blocos para bots de WhatsApp sobre o Baileys",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -29,7 +29,12 @@
29
29
  "./sent": "./packages/sent/index.js",
30
30
  "./context": "./packages/context/index.js",
31
31
  "./commands": "./packages/commands/index.js",
32
- "./kv": "./packages/kv/index.js"
32
+ "./kv": "./packages/kv/index.js",
33
+ "./pending": "./packages/pending/index.js",
34
+ "./links": "./packages/links/index.js",
35
+ "./scheduler": "./packages/scheduler/index.js",
36
+ "./ask": "./packages/ask/index.js",
37
+ "./capture": "./packages/capture/index.js"
33
38
  },
34
39
  "scripts": {
35
40
  "test": "node test/run-all.js"
@@ -0,0 +1,99 @@
1
+ # morpho-socket/ask
2
+
3
+ Conversations that take **more than one message**. A command answers one message; sometimes you need to wait for the next one — "are you sure?", a form with three questions, a hangman where everybody types letters, a quiz answered by replying to the question.
4
+
5
+ ```js
6
+ const { createAsk } = require("morpho-socket/ask")
7
+ const ask = createAsk({ timeoutMs: 60_000 })
8
+ ```
9
+
10
+ ## 1. Wire it once, before the commands
11
+
12
+ ```js
13
+ listen("messages.upsert", async ({ type, messages }) => {
14
+ for (const msg of messages) {
15
+ const g = await contexts.build({ sock, msg, clientId: sessionId })
16
+ if (await ask.route(g)) continue // a listener took this message: it is not a command
17
+ await commands.dispatch(g, { /* … */ })
18
+ }
19
+ })
20
+ ```
21
+
22
+ Optional sugar, so commands can write `g.ask(…)` — put it in `createContext`'s `extend`:
23
+
24
+ ```js
25
+ extend: { ask(opts) { return ask.next(this, opts) } }
26
+ ```
27
+
28
+ ## 2. Wait for the next message of a person — `next`
29
+
30
+ ```js
31
+ await g.reply("Delete everything? (yes/no)")
32
+ const a = await ask.next(g, { match: x => /^(yes|no)$/i.test(x.text), timeoutMs: 30_000 })
33
+ if (!a.ok) return g.reply("Time's up.") // a.reason: "timeout" | "cancelled" | "replaced"
34
+ if (/^yes$/i.test(a.text)) await wipe()
35
+ await a.g.reply("Done") // a.g is the context of the ANSWER
36
+ ```
37
+
38
+ A wizard is just several `next` in a row — pass the previous answer's `g` or the same one:
39
+
40
+ ```js
41
+ const name = await ask.next(g)
42
+ const age = await ask.next(name.g)
43
+ ```
44
+
45
+ By default `next` waits for the **same person** (`from: "sender"`), and a new question from that person replaces the old one.
46
+
47
+ ## 3. Many messages, many people — `open`
48
+
49
+ A listener sits on a chat and receives messages until you close it or it goes idle. Your state lives in `data`.
50
+
51
+ ```js
52
+ ask.open(g, {
53
+ key: "hangman", // one per chat; a second open returns { ok: false, reason: "already-open" }
54
+ from: "anyone", // or [numbers], or (g, listener) => boolean
55
+ timeoutMs: 10 * 60_000, // INACTIVITY: each consumed message renews it
56
+ data: { word: "banana", right: [], wrong: [] },
57
+ onMessage: async (g, l) => {
58
+ if (!/^[a-z]$/i.test(g.text)) return false // false = "not for me": the message goes on to chat, commands, other listeners
59
+ play(l.data, g.text.toLowerCase()) // any other result = consumed
60
+ await g.reply(board(l.data))
61
+ if (finished(l.data)) l.close()
62
+ },
63
+ onClose: (l, why) => why === "timeout" && g.send(g.chatId, "Nobody answered. Game over."),
64
+ })
65
+ ```
66
+
67
+ - `from: [a, b]` makes a two-player game; `from: (g) => g.user.number === turn` gives "only whose turn it is".
68
+ - `replyTo: sent.key.id` accepts only messages that **quote** that message — a quiz:
69
+
70
+ ```js
71
+ const sent = await g.reply("Capital of Brazil?")
72
+ ask.open(g, {
73
+ replyTo: sent.key.id, timeoutMs: 30_000, idle: false, // idle: false = fixed deadline
74
+ onMessage: async (g2, l) => { l.close(); await g2.reply(/bras[ií]lia/i.test(g2.text) ? "Correct!" : "Nope.") },
75
+ })
76
+ ```
77
+
78
+ ## Rules
79
+
80
+ - **Commands are never routed to listeners**, so `!cancel` or `!forca quit` keep working in the middle of a conversation. Pass `commands: true` if a listener wants them.
81
+ - **Per client and chat.** Several listeners can be open at once; the oldest that matches takes the message, and `false` hands it to the next one.
82
+ - **Expected outcomes are values**: `{ ok: false, reason }`. Wrong usage (no `onMessage`, `timeoutMs <= 0`, a bad `from`) throws `TypeError`. A throwing `onMessage` still consumes the message and goes to `log`; it never breaks `route()`.
83
+ - **In memory, on purpose.** A pending promise can't survive a restart. If a game must not be lost, keep its state in [`kv`](../kv) and open the listener again from it when the player comes back. For a request that must survive, see [`pending`](../pending).
84
+
85
+ ## API
86
+
87
+ | | |
88
+ |---|---|
89
+ | `createAsk({ timeoutMs = 60 s, maxPerChat = 20, now?, timers?, log? })` | |
90
+ | `route(g)` → `Promise<boolean>` | `true` = consumed. Call it for every message, before the commands |
91
+ | `next(g, opts?)` → `{ ok: true, g, text }` · `{ ok: false, reason }` | `opts`: `from` (default `"sender"`), `match`, `replyTo`, `commands`, `timeoutMs`, `key` |
92
+ | `open(g, opts)` → `{ ok: true, listener }` · `{ ok: false, reason }` | `reason`: `"already-open"` (+ `listener`) · `"too-many"` · `"unknown-sender"` |
93
+ | `get(g, key)` · `list(g)` · `cancel(g, key)` · `clear(g?)` · `size` | `clear()` with no argument closes everything |
94
+
95
+ `open` options: `key` · `from` · `match(g, listener)` · `replyTo` · `commands` · `timeoutMs` · `idle` (default `true`) · `data` · `onMessage(g, listener)` · `onClose(listener, reason)` · `replace`.
96
+
97
+ A **listener** is `{ id, key, data, from, expiresAt, closed, close(reason?), touch(ms?) }`. `onClose` receives `"timeout"`, `"closed"`, `"cancelled"`, `"replaced"` (or `"answered"` for `next`).
98
+
99
+ `g` is the context from [`context`](../context); `ask` only needs `g.clientId`, `g.chatId`, `g.user.number`, `g.text`, and optionally `g.command` and `g.quoted.id`.
@@ -0,0 +1,183 @@
1
+ // @morpho/ask
2
+ //
3
+ // Conversations that take more than one message. Commands answer ONE message; sometimes you need to wait for the next one:
4
+ // "are you sure?", a form with three questions, a hangman where everybody types letters, a quiz answered by replying.
5
+ //
6
+ // const ask = createAsk({ timeoutMs: 60_000 })
7
+ //
8
+ // // 1) wiring — once, for every message, BEFORE the commands
9
+ // const g = await contexts.build({ sock, msg, clientId })
10
+ // if (await ask.route(g)) continue // a listener took it: don't run it as a command
11
+ // await commands.dispatch(g, { ... })
12
+ //
13
+ // // 2) inside a command: wait for the next message of the same person
14
+ // await g.reply("Delete everything? (yes/no)")
15
+ // const a = await ask.next(g, { match: x => /^(yes|no)$/i.test(x.text) })
16
+ // if (!a.ok) return g.reply("Time's up.") // a.reason: "timeout" | "cancelled" | "replaced"
17
+ // if (/^yes$/i.test(a.text)) { ... } // a.g is the context of the answer: a.g.reply(...) works
18
+ //
19
+ // // 3) many messages, many people: open a listener on the chat
20
+ // ask.open(g, {
21
+ // key: "hangman", from: "anyone", timeoutMs: 10 * 60_000,
22
+ // data: { word: "banana", right: [], wrong: [] }, // your state, kept on the listener
23
+ // onMessage: async (g, l) => {
24
+ // if (!/^[a-z]$/i.test(g.text)) return false // false = "not for me": the message goes on (chat, commands, other listeners)
25
+ // ... // anything else = consumed
26
+ // if (finished) l.close()
27
+ // },
28
+ // onClose: (l, why) => why === "timeout" && send("Game over: nobody answered"),
29
+ // })
30
+ //
31
+ // // reply-to-answer (a quiz): only messages that quote the question
32
+ // const sent = await g.reply("Capital of Brazil?")
33
+ // ask.open(g, { replyTo: sent.key.id, timeoutMs: 30_000, idle: false, onMessage: ... })
34
+ //
35
+ // Rules
36
+ // - A message that is a COMMAND (g.command) is never routed to listeners unless you pass commands: true,
37
+ // so "!cancel" or "!forca quit" keep working in the middle of a conversation.
38
+ // - Listeners are per client and chat. Several can be open at once; the first that matches (oldest first) consumes the message.
39
+ // - Time is INACTIVITY: each consumed message renews it (idle: false = fixed deadline from the start).
40
+ // - Expected outcomes are values: { ok: false, reason }. Wrong usage throws TypeError.
41
+ // - In memory on purpose: a promise can't survive a restart. Keep game state you must not lose in @morpho/kv and re-open
42
+ // the listener from it when the user comes back.
43
+
44
+ const DEFAULT_TIMEOUT_MS = 60_000
45
+ let seq = 0
46
+
47
+ function createAsk({ timeoutMs = DEFAULT_TIMEOUT_MS, maxPerChat = 20, now = Date.now, timers = {}, log = () => {} } = {}) {
48
+ if (!(timeoutMs > 0)) throw new TypeError("ask: timeoutMs must be a positive number")
49
+ const setT = timers.set || ((fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t })
50
+ const clearT = timers.clear || (t => clearTimeout(t))
51
+ const chats = new Map() // "clientId/chatId" -> [listener]
52
+
53
+ const chatKey = g => {
54
+ if (!g || g.clientId == null || !g.chatId) throw new TypeError("ask: pass the g from @morpho/context (needs clientId and chatId)")
55
+ return `${g.clientId}/${g.chatId}`
56
+ }
57
+ const numberOf = g => g?.user?.number ?? null
58
+ const toMs = (v, name) => { if (!(v > 0) || !Number.isFinite(v)) throw new TypeError(`ask: ${name} must be a positive number (ms)`); return v }
59
+
60
+ function finish(l, reason) {
61
+ if (l.closed) return false
62
+ l.closed = true
63
+ clearT(l._timer)
64
+ const list = chats.get(l._chat)
65
+ if (list) { const i = list.indexOf(l); if (i >= 0) list.splice(i, 1); if (!list.length) chats.delete(l._chat) }
66
+ try { l.onClose?.(l, reason) } catch (e) { log("close-error", e) }
67
+ return true
68
+ }
69
+ function arm(l, ms) {
70
+ clearT(l._timer)
71
+ l._ms = ms
72
+ l.expiresAt = now() + ms
73
+ l._timer = setT(() => finish(l, "timeout"), ms)
74
+ }
75
+
76
+ function matchFrom(l, g) {
77
+ const f = l.from
78
+ if (f === "anyone" || f == null) return true
79
+ const n = numberOf(g)
80
+ if (Array.isArray(f)) return n != null && f.map(String).includes(String(n))
81
+ if (typeof f === "function") return !!f(g, l)
82
+ return false
83
+ }
84
+ async function matches(l, g) {
85
+ if (!l.commands && g.command) return false
86
+ if (!matchFrom(l, g)) return false
87
+ if (l.replyTo != null) {
88
+ const id = g.quoted?.id ?? null
89
+ const ok = typeof l.replyTo === "function" ? !!l.replyTo(id) : [].concat(l.replyTo).includes(id)
90
+ if (!ok) return false
91
+ }
92
+ if (l.match && !(await l.match(g, l))) return false
93
+ return true
94
+ }
95
+
96
+ const api = {
97
+ // opts: { key, from: "anyone" | "sender" | [numbers] | (g, l) => bool, match(g, l), replyTo, commands = false,
98
+ // timeoutMs, idle = true, data, onMessage(g, l), onClose(l, reason), replace = false }
99
+ // -> { ok: true, listener } | { ok: false, reason: "already-open" | "too-many" | "unknown-sender", listener? }
100
+ open(g, opts = {}) {
101
+ const ck = chatKey(g)
102
+ const { key, from = "anyone", match, replyTo, commands = false, idle = true, data = null, onMessage, onClose, replace = false } = opts
103
+ if (typeof onMessage !== "function") throw new TypeError("ask.open: pass onMessage(g, listener)")
104
+ const ms = toMs(opts.timeoutMs ?? timeoutMs, "timeoutMs")
105
+ if (key !== undefined && (typeof key !== "string" || !key)) throw new TypeError("ask.open: key must be a non-empty string")
106
+
107
+ let fromList = from
108
+ if (from === "sender") {
109
+ const n = numberOf(g)
110
+ if (n == null) return { ok: false, reason: "unknown-sender" }
111
+ fromList = [String(n)]
112
+ } else if (!(from === "anyone" || Array.isArray(from) || typeof from === "function")) {
113
+ throw new TypeError('ask.open: from must be "anyone", "sender", an array of numbers or a function')
114
+ }
115
+
116
+ const list = chats.get(ck) || []
117
+ const existing = key !== undefined ? list.find(x => x.key === key) : null
118
+ if (existing && !replace) return { ok: false, reason: "already-open", listener: existing }
119
+ if (!existing && list.length >= maxPerChat) return { ok: false, reason: "too-many" }
120
+ if (existing) finish(existing, "replaced")
121
+
122
+ const l = {
123
+ id: ++seq, key: key ?? null, data, from: fromList, match, replyTo, commands, idle, onMessage, onClose,
124
+ closed: false, expiresAt: 0, _chat: ck, _timer: null, _ms: ms,
125
+ close(reason = "closed") { return finish(l, reason) },
126
+ touch(extra) { if (!l.closed) arm(l, extra === undefined ? l._ms : toMs(extra, "touch")) },
127
+ }
128
+ const fresh = chats.get(ck) || []
129
+ fresh.push(l); chats.set(ck, fresh)
130
+ arm(l, ms)
131
+ return { ok: true, listener: l }
132
+ },
133
+
134
+ // wait for ONE message. opts: as open() (from defaults to "sender"; key defaults to "next:<number>" so a new question
135
+ // from the same person replaces the old one). -> { ok: true, g, text } | { ok: false, reason: "timeout" | "cancelled" | "replaced" | ... }
136
+ next(g, opts = {}) {
137
+ const { onMessage, onClose, data, ...rest } = opts
138
+ const from = rest.from ?? "sender"
139
+ const key = rest.key ?? (from === "sender" ? `next:${numberOf(g)}` : undefined)
140
+ return new Promise(resolve => {
141
+ const r = api.open(g, {
142
+ ...rest, from, key, replace: rest.replace ?? true,
143
+ onMessage: (gg, l) => { l.close("answered"); resolve({ ok: true, g: gg, text: gg.text ?? "" }) },
144
+ onClose: (l, why) => { if (why !== "answered") resolve({ ok: false, reason: why === "closed" ? "cancelled" : why }) },
145
+ })
146
+ if (!r.ok) resolve({ ok: false, reason: r.reason })
147
+ })
148
+ },
149
+
150
+ // call this for every message BEFORE the commands. true = a listener consumed it.
151
+ async route(g) {
152
+ const list = chats.get(chatKey(g))
153
+ if (!list) return false
154
+ for (const l of [...list]) {
155
+ if (l.closed) continue
156
+ if (l.expiresAt <= now()) { finish(l, "timeout"); continue } // the timer was late: expire here too
157
+ let ok = false
158
+ try { ok = await matches(l, g) } catch (e) { log("match-error", e); continue }
159
+ if (!ok || l.closed) continue
160
+ if (l.idle) arm(l, l._ms)
161
+ let r
162
+ try { r = await l.onMessage(g, l) } catch (e) { log("handler-error", e); r = true } // a broken handler still consumed it
163
+ if (r !== false) return true
164
+ }
165
+ return false
166
+ },
167
+
168
+ get(g, key) { return (chats.get(chatKey(g)) || []).find(l => l.key === key) || null },
169
+ list(g) { return [...(chats.get(chatKey(g)) || [])] },
170
+ cancel(g, key) { const l = api.get(g, key); return l ? finish(l, "cancelled") : false },
171
+ // close every listener of a chat (or of everything, with no argument). Returns how many.
172
+ clear(g) {
173
+ const lists = g === undefined ? [...chats.values()] : [chats.get(chatKey(g)) || []]
174
+ let n = 0
175
+ for (const list of lists) for (const l of [...list]) if (finish(l, "cancelled")) n++
176
+ return n
177
+ },
178
+ get size() { let n = 0; for (const list of chats.values()) n += list.length; return n },
179
+ }
180
+ return api
181
+ }
182
+
183
+ module.exports = { createAsk, DEFAULT_TIMEOUT_MS }
@@ -0,0 +1 @@
1
+ { "name": "@morpho/ask", "version": "0.1.0", "description": "Multi-step conversations: wait for the next message of a person, or open a listener on a chat (games, wizards, confirmations, reply-to-answer). Routes before commands; ignores command messages by default; expires by inactivity.", "main": "index.js", "license": "MIT" }
@@ -0,0 +1,49 @@
1
+ # morpho-socket/capture
2
+
3
+ A **capture tool** for what Baileys delivers. When WhatsApp changes something (like the move to `@lid`), you see it here first, as an alert, before a bot notices.
4
+
5
+ ```js
6
+ const { createCapture } = require("morpho-socket/capture")
7
+
8
+ const capture = createCapture({ dir: "./capture" }) // writes ./capture/capture.ndjson (+ one rotated file)
9
+ capture.attachPool(pool) // follows every (re)connection of the pool
10
+ // or, without the pool: const detach = capture.attach(sock, { sessionId: "main" })
11
+
12
+ console.log(capture.reportText()) // counts, LID with/without number, alerts
13
+ capture.exportFixtures("./test/fixtures/baileys.json")
14
+ ```
15
+
16
+ ## What it records
17
+
18
+ | Source | How | What is kept |
19
+ |---|---|---|
20
+ | Events (`messages.upsert`, `messages.update`, `lid-mapping.update`, `group-participants.update`, `groups.*`, `contacts.*`, `connection.update`, `call`) | `sock.ev.on` | the **shape**: keys, JIDs (masked), message type, enum-like fields |
21
+ | Raw nodes (`message`, `notification`, `call`) | `sock.ws.on("frame")`, every decoded node before Baileys interprets it | tag, attributes (JIDs masked), children, binary payloads as `<bin:N>` |
22
+
23
+ Outgoing nodes are not captured: Baileys sends them through an internal function that cannot be hooked from outside, and what goes on the wire is encrypted anyway.
24
+
25
+ ## Alerts
26
+
27
+ - `lid_without_number` (warn): a `@lid` sender arrived and the field that should carry the number (`participantAlt` in groups, `remoteJidAlt` in private) is missing.
28
+ - `frame_lid_without_number` (warn): the same, one layer lower, on the raw node (`addressing_mode=lid` without `participant_pn` / `sender_pn` / `peer_recipient_pn`).
29
+ - `new_field` (info): a field that was not in the first sample of that event showed up. After a Baileys update this is the list of what changed.
30
+
31
+ Alerts also go to the console (`console: "alerts"` by default, `true` for one line per event, `false` for silence).
32
+
33
+ ## Privacy
34
+
35
+ - Message text, push names, captions, QR codes, credentials and binary content are **never** written. Strings become `<str:N>` unless they are a JID or an enum-like field (`type`, `action`, `connection`...).
36
+ - Numbers are masked with a keyed hash (salt in `capture/.salt`): the same number always becomes the same fake number, with the same length and JID shape, so you can still follow "who is who" and replay the fixtures through `morpho-socket/jid`.
37
+ - Even masked, the files describe who talks to whom. Treat the folder like the media cache: keep it out of git, and delete it when you are done. Do not share the `.salt` file.
38
+
39
+ ## Fixtures
40
+
41
+ `exportFixtures(file)` keeps up to `perShape` (default 2) samples of each distinct shape, including the ones that raised alerts. `loadFixtures(file)` reads them back. Fixtures hold identity and structure (keys, JIDs, types), not content, which is what the `jid`, `message` and `context` layers need. Use them to run the same input through the Baileys path and through the Morpho path and compare the outputs.
42
+
43
+ ## Rules it never breaks
44
+
45
+ It never throws into the bot (every listener is wrapped; internal failures are only counted in `report().errors`), and the file is a ring: when it passes `maxBytes` (default 1 MB) it becomes `capture.1.ndjson` and a new one starts.
46
+
47
+ ## Options
48
+
49
+ `dir`, `maxBytes`, `mask` (`false` turns masking off, only for local debugging), `events`, `frameTags`, `console`, `salt`, `baileysVersion` (read from the bot's Baileys when omitted).