morpho-socket 0.2.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 +5 -1
- package/docs/building-a-bot.md +1 -1
- package/package.json +21 -6
- package/packages/ask/README.md +99 -0
- package/packages/ask/index.js +183 -0
- package/packages/ask/package.json +1 -0
- package/packages/capture/README.md +49 -0
- package/packages/capture/index.js +324 -0
- package/packages/capture/package.json +1 -0
- package/packages/context/README.md +3 -0
- package/packages/context/group.js +24 -0
- package/packages/context/index.js +1 -1
- package/packages/links/README.md +85 -0
- package/packages/links/index.js +191 -0
- package/packages/links/package.json +1 -0
- package/packages/pending/README.md +47 -0
- package/packages/pending/index.js +117 -0
- package/packages/pending/package.json +1 -0
- package/packages/scheduler/README.md +79 -0
- package/packages/scheduler/index.js +251 -0
- package/packages/scheduler/package.json +1 -0
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/docs/building-a-bot.md
CHANGED
|
@@ -97,7 +97,7 @@ const g = await contexts.build({ sock, msg, clientId })
|
|
|
97
97
|
| who it's about | `await g.who()` — the mentioned person, else the replied-to author |
|
|
98
98
|
| permissions | `g.isOwner` · `await g.isAdmin()` · `await g.isBotAdmin()` |
|
|
99
99
|
| to answer | `g.reply(…)` · `g.send(jid, …)` · `g.react("👍")` · `g.edit(sent, …)` |
|
|
100
|
-
| group info and actions | `g.group()` · `g.members()` · `g.kick()` · `g.promote()` · … |
|
|
100
|
+
| group info and actions | `g.group()` · `g.members()` · `g.kick()` · `g.promote()` · `g.tagAll(text)` · … (`g.readMore` = the "Read more…" fold) |
|
|
101
101
|
| media | `g.download()` · `g.media()` |
|
|
102
102
|
|
|
103
103
|
Your bot's rules plug in through `createContext` options and `extend`:
|
package/package.json
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morpho-socket",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Blocos para bots de WhatsApp sobre o Baileys",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"engines": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@whiskeysockets/baileys": "*"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"packages",
|
|
14
|
+
"README.md",
|
|
15
|
+
"docs"
|
|
16
|
+
],
|
|
9
17
|
"exports": {
|
|
10
18
|
"./core": "./packages/core/index.js",
|
|
11
19
|
"./store-sqlite": "./packages/store-sqlite/index.js",
|
|
@@ -21,7 +29,14 @@
|
|
|
21
29
|
"./sent": "./packages/sent/index.js",
|
|
22
30
|
"./context": "./packages/context/index.js",
|
|
23
31
|
"./commands": "./packages/commands/index.js",
|
|
24
|
-
"./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"
|
|
25
38
|
},
|
|
26
|
-
"scripts": {
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node test/run-all.js"
|
|
41
|
+
}
|
|
27
42
|
}
|
|
@@ -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).
|