morpho-socket 0.0.0-stage → 0.2.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 +25 -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 +97 -0
  9. package/packages/context/group.js +245 -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
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # morpho-socket
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Building blocks for WhatsApp bots, on top of [Baileys](https://github.com/WhiskeySockets/Baileys).
4
+
5
+ Baileys speaks the WhatsApp protocol. **Morpho takes care of everything around it** — the parts every serious bot ends up rewriting: running several numbers in one process, knowing who actually sent a message (LID → phone number), replying without losing mentions, downloading media whose link expired, caching group metadata, loading commands from folders, and keeping bot data safe in SQLite.
6
+
7
+ Every piece is a small, independent module. Use the whole stack, or only the parts you need.
8
+
9
+ ```bash
10
+ npm install morpho-socket @whiskeysockets/baileys
11
+ ```
12
+
13
+ > **Unofficial.** Morpho is not affiliated with, endorsed by, or connected to WhatsApp or Meta. Automating WhatsApp accounts can violate its Terms of Service; use it at your own risk and respect people's privacy.
14
+
15
+ ## Requirements
16
+
17
+ - **Node.js ≥ 22.5** (`store-sqlite` and `kv` use the built-in `node:sqlite`)
18
+ - **Baileys** installed in your own project (Morpho loads it from there; it is not bundled)
19
+ - **ffmpeg** on the `PATH`, only if you send voice notes from non-Opus audio
20
+
21
+ Morpho is CommonJS.
22
+
23
+ ## What problems does it solve?
24
+
25
+ | Problem | Module |
26
+ |---|---|
27
+ | One folder full of loose JSON files per client | [`store-sqlite`](packages/store-sqlite) — every session in one `.sqlite` file |
28
+ | Several numbers in one process: pairing, reconnecting, banned/replaced sessions | [`pool`](packages/pool) |
29
+ | "Who sent this?" — the message carries a `@lid`, not a phone number | [`jid`](packages/jid) |
30
+ | The real message is buried under `ephemeralMessage`, `viewOnceMessageV2`, `editedMessage`… | [`message`](packages/message) |
31
+ | Replying to a message that mentions someone shows a raw `@123…` in the quote | [`reply`](packages/reply) |
32
+ | "Is the sender an admin?" hits WhatsApp on every message | [`groups`](packages/groups) |
33
+ | Media download fails because the CDN link expired | [`media`](packages/media) |
34
+ | Native buttons and lists (Baileys has no helper) | [`interactive`](packages/interactive) |
35
+ | "typing…" indicators and a minimum response delay | [`presence`](packages/presence) |
36
+ | Re-sending undecryptable messages / opening poll votes | [`sent`](packages/sent) |
37
+ | One object with everything about the current message | [`context`](packages/context) |
38
+ | Commands from folders, aliases, admin/owner guards, antiflood | [`commands`](packages/commands) |
39
+ | Bot data (economy, settings, cooldowns) with real transactions | [`kv`](packages/kv) |
40
+
41
+ ## A quick look
42
+
43
+ The whole stack wired together — a multi-number bot with one command:
44
+
45
+ ```js
46
+ const path = require("path")
47
+ const { initAuthCreds } = require("@whiskeysockets/baileys")
48
+ const { MorphoPool } = require("morpho-socket/pool")
49
+ const sqlite = require("morpho-socket/store-sqlite")
50
+ const { createIdentity } = require("morpho-socket/jid")
51
+ const { createGroups } = require("morpho-socket/groups")
52
+ const { createSentStore } = require("morpho-socket/sent")
53
+ const { createMedia } = require("morpho-socket/media")
54
+ const { createContext } = require("morpho-socket/context")
55
+ const { createCommands } = require("morpho-socket/commands")
56
+
57
+ const store = sqlite.create({ path: "./data/sessions.sqlite" })
58
+ const identity = createIdentity({ store }) // the LID → number map survives restarts
59
+
60
+ const contexts = createContext({
61
+ identity,
62
+ media: createMedia(),
63
+ prefix: "!",
64
+ isOwner: g => g.user.phone === "5511999999999", // your rules plug in here
65
+ })
66
+
67
+ const commands = createCommands({ dirs: [{ type: "user", dir: path.join(__dirname, "commands", "user") }], log: console.log })
68
+ commands.load()
69
+
70
+ // groups + sent are per socket, so keep them per session
71
+ const perSession = new Map()
72
+ const pool = new MorphoPool({
73
+ store,
74
+ initCreds: initAuthCreds,
75
+ socketConfig: sessionId => { // extra options for Baileys' makeWASocket
76
+ const groups = createGroups(), sent = createSentStore()
77
+ perSession.set(sessionId, { groups, sent })
78
+ return { cachedGroupMetadata: groups.cachedGroupMetadata, getMessage: key => sent.get(key) }
79
+ },
80
+ })
81
+
82
+ // the pool hands you a fresh socket on every (re)connection
83
+ pool.on("socket", (sessionId, sock, { listen }) => {
84
+ const { groups, sent } = perSession.get(sessionId)
85
+ groups.install(sock) // cached group metadata + admin checks
86
+ sent.install(sock) // remembers what the bot sent
87
+ identity.install(sock, sessionId) // learns LID ↔ number from socket events
88
+
89
+ listen("messages.upsert", async ({ type, messages }) => {
90
+ if (type !== "notify") return
91
+ for (const msg of messages) {
92
+ const g = await contexts.build({ sock, msg, clientId: sessionId })
93
+ await commands.dispatch(g)
94
+ }
95
+ })
96
+ })
97
+
98
+ pool.on("pairing-code", (id, code) => console.log(`${id}: enter ${code} on the phone`))
99
+ pool.on("logged-out", id => console.log(`${id} was logged out`))
100
+
101
+ await pool.startAll([{ sessionId: "my-bot", phoneNumber: "5511999999999" }])
102
+ ```
103
+
104
+ ```js
105
+ // commands/user/ping.js
106
+ module.exports = {
107
+ name: "ping",
108
+ aliases: ["p"],
109
+ async run(g) {
110
+ await g.reply(`pong, ${g.user.name}`)
111
+ },
112
+ }
113
+ ```
114
+
115
+ ## How the pieces fit
116
+
117
+ ```
118
+ ┌────────────── Baileys (WhatsApp protocol) ──────────────┐
119
+ │ │
120
+ store-sqlite/file ──▶ core ──▶ pool ── "socket" event ──▶ messages.upsert │
121
+ (where sessions live) (auth) (many numbers, reconnect) │ │
122
+ ▼ │
123
+ message.parse + jid (who sent it?) │
124
+ │ │
125
+ context ──▶ `g` (one object) │
126
+ │ │
127
+ commands.dispatch ──▶ your command.run(g) │
128
+ │ │
129
+ reply · media · interactive · presence ◀───┘ │
130
+ groups (cache) · sent (getMessage) · kv (your data) │
131
+ └─────────────────────────────────────────────────────────┘
132
+ ```
133
+
134
+ ## Design principles
135
+
136
+ - **No business rules inside.** Plans, limits, who the owner is, what "banned" means — that is your bot. Morpho exposes hooks and events.
137
+ - **Expected failures are values, not exceptions.** `g.kick()` returns `{ ok: false, reason: "target-is-admin" }`; media errors carry a stable `.code`; identity never throws (a message is never lost because a lookup failed).
138
+ - **Morpho never writes user-facing text for you.** It gives you stable reason codes; you decide what to say (and in which language).
139
+ - **Plain data in, plain data out.** `message.parse()` returns a simple object; `g.who()` returns a `Person`; stores are six-method duck-typed objects.
140
+ - **Everything is injectable and testable without WhatsApp.** Sockets, downloaders and clocks can be replaced, so the whole suite runs with mocks.
141
+
142
+ ## Documentation
143
+
144
+ - **[Building a bot with Morpho](docs/building-a-bot.md)** — a tour of every feature and how to combine them.
145
+ - 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)
147
+
148
+ ## Testing
149
+
150
+ ```bash
151
+ npm test # every suite, no WhatsApp connection needed
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT. Built on top of Baileys (MIT); Morpho does not reimplement any part of the WhatsApp protocol.
@@ -0,0 +1,402 @@
1
+ # Building a bot with Morpho
2
+
3
+ A tour of what Morpho gives you and how the pieces combine. This is not a "from zero" tutorial — it assumes you know what a WhatsApp bot is and want to see what you can build with each part. Every module has its own README with the full API; this guide shows how they fit together.
4
+
5
+ - [The mental model](#the-mental-model)
6
+ - [Sessions: many numbers, one process](#sessions-many-numbers-one-process)
7
+ - [Understanding incoming messages](#understanding-incoming-messages)
8
+ - [The `g` context](#the-g-context)
9
+ - [Commands](#commands)
10
+ - [Replying and sending](#replying-and-sending)
11
+ - [Managing groups](#managing-groups)
12
+ - [Media](#media)
13
+ - [Buttons and lists](#buttons-and-lists)
14
+ - [Feeling natural: presence and delays](#feeling-natural-presence-and-delays)
15
+ - [Resilience](#resilience)
16
+ - [Your bot's data](#your-bots-data)
17
+ - [Putting it together](#putting-it-together)
18
+ - [Production notes](#production-notes)
19
+
20
+ ## The mental model
21
+
22
+ Baileys owns the connection. Morpho owns the plumbing between "a socket exists" and "your command runs":
23
+
24
+ ```
25
+ store ──▶ pool ──▶ socket ──▶ messages.upsert
26
+ (sessions) (numbers) │
27
+ ▼
28
+ message.parse + jid.resolveSender
29
+ │
30
+ context.build ──▶ g
31
+ │
32
+ commands.dispatch ──▶ your command.run(g)
33
+ │
34
+ g.reply · g.kick · g.download · …
35
+ ```
36
+
37
+ Each arrow is a separate module you can replace or skip. You can use `message` and `jid` inside a hand-written handler without ever touching `commands`, or use `pool` with your own message logic.
38
+
39
+ Morpho has **no business rules**. Who the owner is, what a plan includes, which groups are enabled — those are yours, plugged in through options and hooks.
40
+
41
+ ## Sessions: many numbers, one process
42
+
43
+ **Where sessions live** is a one-line choice. `store-sqlite` keeps every client in a single `.sqlite` file; `store-file` keeps one folder per client (a bridge if you come from `useMultiFileAuthState`). Writing your own backend is six methods ([`core`](../packages/core)).
44
+
45
+ **How they run** is [`pool`](../packages/pool):
46
+
47
+ ```js
48
+ const pool = new MorphoPool({ store, initCreds: initAuthCreds })
49
+ await pool.startAll(["client-a", { sessionId: "client-b", phoneNumber: "5511999999999" }])
50
+ ```
51
+
52
+ - A session with no saved credentials and a `phoneNumber` gets a **pairing code** (`pool.on("pairing-code", …)`).
53
+ - Drops are handled by policy: network errors reconnect with backoff, `515` (restart after pairing) reconnects immediately, and `401` logged-out, `403` banned/restricted and `440` replaced stop for good — retrying those only makes things worse. Each one has its own event so you can notify the client.
54
+ - `stop(id)` keeps the session; `reset(id)` deletes it. `info(id)` / `list()` feed a dashboard.
55
+ - `maxAttemptsWithoutPairing` stops a number nobody ever paired from retrying forever (`gave-up`).
56
+
57
+ The per-socket hook is the `socket` event. It fires on **every** (re)connection with a brand-new socket, and hands you a safe `listen`:
58
+
59
+ ```js
60
+ pool.on("socket", (sessionId, sock, { listen }) => {
61
+ listen("messages.upsert", async data => { /* errors here go to pool "error", never crash the process */ })
62
+ })
63
+ ```
64
+
65
+ ## Understanding incoming messages
66
+
67
+ Two modules turn raw Baileys data into something you can reason about.
68
+
69
+ **[`message`](../packages/message)** — `parse(msg)` unwraps view-once, ephemeral, edited and caption wrappers and returns one flat shape: `type`, `text`, `mentions`, `media`, `quoted`, `selection` (button taps), `reaction`, `edited`/`revoked`…
70
+
71
+ ```js
72
+ const m = parse(msg)
73
+ if (m.is("image", "video") && m.text.startsWith("!sticker")) { /* … */ }
74
+ if (m.quoted?.media) { /* the user replied to a media message */ }
75
+ ```
76
+
77
+ **[`jid`](../packages/jid)** — answers "who is this, really?". WhatsApp may deliver a LID instead of a phone number; `resolveSender` always returns the clean number (or `null`, with the `source` of the answer), using the cheapest source first and learning as it goes. Pass it a store and what it learns survives restarts.
78
+
79
+ ```js
80
+ const who = await identity.resolveSender(msg, sock, clientId)
81
+ who.number // "5511968111161" — your database key
82
+ who.formatted // "+55 (11) 96811-1161"
83
+ ```
84
+
85
+ ## The `g` context
86
+
87
+ [`context`](../packages/context) combines the above into the single object every command receives. You build it once per message:
88
+
89
+ ```js
90
+ const g = await contexts.build({ sock, msg, clientId })
91
+ ```
92
+
93
+ | You want | You write |
94
+ |---|---|
95
+ | the text / the command / its arguments | `g.text` · `g.command` · `g.args` · `g.argText` |
96
+ | who sent it | `g.user.number` (never empty — your DB key) · `g.user.phone` · `g.user.name` |
97
+ | who it's about | `await g.who()` — the mentioned person, else the replied-to author |
98
+ | permissions | `g.isOwner` · `await g.isAdmin()` · `await g.isBotAdmin()` |
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()` · … |
101
+ | media | `g.download()` · `g.media()` |
102
+
103
+ Your bot's rules plug in through `createContext` options and `extend`:
104
+
105
+ ```js
106
+ const contexts = createContext({
107
+ identity, media, interactive,
108
+ prefixFor: (g, chatId) => settings.get(`${g.clientId}/${chatId}/prefix`, "!"), // per-group prefix
109
+ isOwner: g => OWNERS.has(g.user.number),
110
+ protect: (g, person) => OWNERS.has(person.number), // g.kick() will refuse
111
+ extend: {
112
+ get plan() { return clients.get(this.clientId)?.plan ?? "free" }, // g.plan in every command
113
+ },
114
+ })
115
+ ```
116
+
117
+ Extra properties passed to `build()` are copied onto `g` too — handy for handing commands your database handle.
118
+
119
+ ## Commands
120
+
121
+ [`commands`](../packages/commands) is the generic half of a command bot. Commands are files:
122
+
123
+ ```
124
+ commands/
125
+ user/
126
+ ping.js
127
+ economy/
128
+ balance.js → category "economy"
129
+ admin/
130
+ ban.js → needs: group + admin sender + admin bot
131
+ owner/
132
+ broadcast.js → needs: g.isOwner
133
+ ```
134
+
135
+ ```js
136
+ // commands/admin/ban.js
137
+ module.exports = {
138
+ name: "ban",
139
+ aliases: ["kick"],
140
+ async run(g) {
141
+ const r = await g.kick()
142
+ if (!r.ok) return g.reply(ERRORS[r.reason] ?? ERRORS.failed)
143
+ await g.reply(`Removed ${await g.at(r.jid)}`)
144
+ },
145
+ }
146
+ ```
147
+
148
+ What the loader gives you for free: aliases, categories from folders, `_helper.js` / `*.test.js` ignored, and **warnings** for repeated names, a mistyped `aliasee`, broken files and commands missing `name`/`run` — one broken file never stops the rest.
149
+
150
+ Everything that is your business goes into `dispatch` hooks:
151
+
152
+ ```js
153
+ await commands.dispatch(g, {
154
+ before: async (g, command) => {
155
+ if (!settings.isEnabled(g, command)) return false // per-group switch
156
+ const flood = antiflood.check(`${g.clientId}:${g.user.number}`, floodConfig)
157
+ if (!flood.ok) { if (flood.warn) await g.reply(`Slow down (${flood.wait}s)`); return false }
158
+ },
159
+ onNotFound: async (g, { name }) => suggestSimilar(g, name), // "did you mean…?"
160
+ run: (g, command, args, exec) => // wrap execution: typing indicator, timing, stats
161
+ presence.during(g.sock, g.chatId, "composing", exec, { delayMs: 1200 }),
162
+ })
163
+ ```
164
+
165
+ ## Replying and sending
166
+
167
+ One door: `g.reply()` for the current chat, `g.send(jid, …)` for any other.
168
+
169
+ ```js
170
+ await g.reply("text") // quotes the command, keeps mentions
171
+ await g.reply({ image: buffer, caption: "look" }) // image | video | gif | sticker | voice | audio | document
172
+ await g.reply(`congrats ${await g.at(person)}!`) // a real, clickable mention
173
+ const sent = await g.reply("working…")
174
+ await g.edit(sent, "done") // edit your own message
175
+ await g.poll("Pizza?", ["yes", "no"])
176
+ ```
177
+
178
+ Details you don't have to handle:
179
+
180
+ - **Mentions survive quoting.** Baileys drops the original mentions when quoting, which shows a raw `@123…` in the bubble; Morpho rebuilds the quote ([`reply`](../packages/reply)).
181
+ - **`g.at(person)`** writes the `@id` into your text *and* registers the mention, using the participant's native id so WhatsApp swaps it for their name.
182
+ - **A command is never mute.** If quoting fails it sends without the quote; if media fails and there's a caption, the caption is sent.
183
+ - Media sources can be a `Buffer`, a URL, a file path or `{ url }`. Voice notes are converted to ogg/opus with ffmpeg.
184
+
185
+ ## Managing groups
186
+
187
+ Every group action has the same shape and **never throws**:
188
+
189
+ ```js
190
+ const r = await g.kick(["5511911111111", "5511922222222"]) // one WhatsApp call for many people
191
+ // → { ok: false, action: "kick", results: [{ ok: true, … }, { ok: false, reason: "target-is-admin", … }] }
192
+ ```
193
+
194
+ You get a stable `reason` and decide what to tell the user (and in which language) — Morpho never writes user-facing text:
195
+
196
+ ```js
197
+ const ERRORS = {
198
+ "bot-not-admin": "Make me an admin first.",
199
+ "target-is-admin": "I won't remove an admin.",
200
+ "not-in-group": "That person isn't in this group.",
201
+ failed: "Something went wrong.",
202
+ }
203
+ ```
204
+
205
+ Available: `kick` `promote` `demote` `add` `approve` `reject` · `close` `open` `lock` `unlock` `rename` `setDescription` `leave` · `link` `revokeLink` · `delete`. Safe defaults protect the bot, the group creator and admins; `protect` adds your own untouchables. `add` returns `needs-invite` with the invite link when privacy settings block a direct add.
206
+
207
+ Behind the scenes [`groups`](../packages/groups) keeps this fast: metadata is cached with a TTL, simultaneous lookups collapse into one, stale data is served when WhatsApp refuses, and the cache is dropped the moment a group changes.
208
+
209
+ ## Media
210
+
211
+ **Receiving.** `g.download()` finds the media (the replied-to message wins, so `!sticker` as a reply to an image works), downloads it with a size limit, timeout, retry and cache, and returns a `Buffer` or `null`.
212
+
213
+ ```js
214
+ const buffer = await g.download({ types: ["image"], max: 10 * 1024 * 1024 })
215
+ if (!buffer) return g.reply("Send or reply to an image.")
216
+ ```
217
+
218
+ When WhatsApp's CDN link has expired, [`media`](../packages/media) asks the phone to re-send the file once and downloads again — the case that makes most bots answer "couldn't download". Errors have stable codes (`MEDIA_TOO_LARGE`, `MEDIA_EXPIRED`, `MEDIA_TIMEOUT`…) so you can answer sensibly.
219
+
220
+ Privacy defaults: view-once media is never returned unless asked (`viewOnce: true`) and never cached; the optional disk cache is `0700`/`0600` with an expiry.
221
+
222
+ **Sending.** `g.reply({ image | video | gif | sticker | voice | audio | document })` — see above.
223
+
224
+ ## Buttons and lists
225
+
226
+ Native buttons and selection lists work through [`interactive`](../packages/interactive):
227
+
228
+ ```js
229
+ const ui = createInteractive({ mark: "mybot:" }) // the same instance you pass to createContext
230
+
231
+ await g.reply({
232
+ body: "What next?",
233
+ image: menuImage, imageId: "menu", // upload reused for 6 hours
234
+ buttons: [
235
+ ui.button.reply("Games", "menu games"),
236
+ ui.button.link("Website", "https://example.com"),
237
+ ui.button.list("More", [ui.section("Fun", [ui.row("Dice", "roll a die", "dice")])]),
238
+ ],
239
+ fallback: "Reply with !menu games or !dice", // used if the interactive send fails
240
+ })
241
+ ```
242
+
243
+ A tap arrives as ordinary text (`g.text === "menu games"`), so your commands handle it exactly like a typed command. Taps on another bot's menu in the same group are ignored. This feature relies on undocumented WhatsApp behavior — keep the `fallback`.
244
+
245
+ ## Feeling natural: presence and delays
246
+
247
+ [`presence`](../packages/presence) does two things:
248
+
249
+ - `during(sock, jid, "composing", fn, { delayMs })` shows "typing…" while `fn` runs (renewed until it ends; skipped if it finishes before `delayMs`).
250
+ - `createResponseDelay` enforces a minimum answer time **measured from when the message arrived**, for every command, without touching any of them.
251
+
252
+ ```js
253
+ const delay = createResponseDelay({ delayMs: () => settings.delayMs, showTyping: () => true })
254
+ delay.install(sock)
255
+ await delay.run(() => handle(msg))
256
+ ```
257
+
258
+ Both read their settings through functions, so a dashboard can change them live.
259
+
260
+ ## Resilience
261
+
262
+ Small things that stop bots from failing silently:
263
+
264
+ - **[`sent`](../packages/sent)** — remembers what you sent so Baileys' `getMessage` can re-send undecryptable messages and open poll votes.
265
+ - **[`groups`](../packages/groups)** — cached metadata means group sends and admin checks don't hammer WhatsApp.
266
+ - **[`pool`](../packages/pool)** — caches the protocol version (with a timeout and fallback), spreads out `startAll`, never recreates a half-finished pairing, and turns listener errors into events instead of crashes.
267
+ - **Identity never throws** — a message is never lost because a LID lookup failed.
268
+
269
+ ## Your bot's data
270
+
271
+ [`kv`](../packages/kv) is a single SQLite file for everything that isn't a WhatsApp session:
272
+
273
+ ```js
274
+ const db = openKV("data/bot.sqlite")
275
+ const eco = db.collection("economy")
276
+
277
+ db.transaction(() => { // all-or-nothing transfer
278
+ eco.update(from, u => { if (u.coins < n) throw new Error("insufficient funds"); return { ...u, coins: u.coins - n } })
279
+ eco.update(to, u => ({ ...u, coins: u.coins + n }))
280
+ })
281
+
282
+ db.collection("cooldowns").set(`${g.user.number}:daily`, true, { ttl: 24 * 3600e3 }) // expires by itself
283
+ eco.top("coins", { limit: 10 }) // leaderboard
284
+ const perGroup = db.collection("settings").sub(g.clientId, g.chatId) // isolated per client and group
285
+ ```
286
+
287
+ Use `g.user.number` as the person key and `sub(clientId, chatId)` for per-group data, so clients never see each other's data.
288
+
289
+ ## Putting it together
290
+
291
+ A complete, multi-number bot. Everything marked "yours" is where your own rules go.
292
+
293
+ ```js
294
+ // index.js
295
+ const path = require("path")
296
+ const { initAuthCreds } = require("@whiskeysockets/baileys")
297
+ const { MorphoPool } = require("morpho-socket/pool")
298
+ const sqlite = require("morpho-socket/store-sqlite")
299
+ const { createIdentity } = require("morpho-socket/jid")
300
+ const { createGroups } = require("morpho-socket/groups")
301
+ const { createSentStore } = require("morpho-socket/sent")
302
+ const { createMedia } = require("morpho-socket/media")
303
+ const { createInteractive } = require("morpho-socket/interactive")
304
+ const { createPresence } = require("morpho-socket/presence")
305
+ const { createContext } = require("morpho-socket/context")
306
+ const { createCommands, createAntiflood } = require("morpho-socket/commands")
307
+ const { openKV } = require("morpho-socket/kv")
308
+
309
+ const store = sqlite.create({ path: "./data/sessions.sqlite" })
310
+ const db = openKV("./data/bot.sqlite")
311
+ const identity = createIdentity({ store })
312
+ const presence = createPresence()
313
+ const antiflood = createAntiflood()
314
+ const ui = createInteractive({ mark: "mybot:" })
315
+
316
+ const contexts = createContext({
317
+ identity,
318
+ media: createMedia({ disk: { dir: "./data/media-cache" } }),
319
+ interactive: ui,
320
+ prefix: "!",
321
+ isOwner: g => g.user.phone === process.env.OWNER_NUMBER, // yours
322
+ extend: { db }, // yours: g.db in every command
323
+ })
324
+
325
+ const commands = createCommands({
326
+ dirs: [
327
+ { type: "user", dir: path.join(__dirname, "commands", "user") },
328
+ { type: "admin", dir: path.join(__dirname, "commands", "admin") },
329
+ { type: "owner", dir: path.join(__dirname, "commands", "owner") },
330
+ ],
331
+ log: (level, text) => console[level === "error" ? "error" : "log"](text),
332
+ })
333
+ commands.load()
334
+
335
+ // groups + sent are per socket, so keep them per session
336
+ const perSession = new Map()
337
+
338
+ const pool = new MorphoPool({
339
+ store,
340
+ initCreds: initAuthCreds,
341
+ maxAttemptsWithoutPairing: 8,
342
+ socketConfig: sessionId => {
343
+ const groups = createGroups(), sent = createSentStore()
344
+ perSession.set(sessionId, { groups, sent })
345
+ return { cachedGroupMetadata: groups.cachedGroupMetadata, getMessage: key => sent.get(key) }
346
+ },
347
+ })
348
+
349
+ pool.on("socket", (sessionId, sock, { listen }) => {
350
+ const { groups, sent } = perSession.get(sessionId)
351
+ groups.install(sock)
352
+ sent.install(sock)
353
+ identity.install(sock, sessionId)
354
+
355
+ listen("messages.upsert", async ({ type, messages }) => {
356
+ if (type !== "notify") return
357
+ for (const msg of messages) {
358
+ if (msg.key.fromMe) continue
359
+ const g = await contexts.build({ sock, msg, clientId: sessionId })
360
+
361
+ await commands.dispatch(g, {
362
+ before: async () => {
363
+ const f = antiflood.check(`${sessionId}:${g.user.number}`, { enabled: true, commands: 5, windowSec: 10, blockSec: 30 })
364
+ if (!f.ok) { if (f.warn) await g.reply(`Slow down — wait ${f.wait}s`); return false }
365
+ },
366
+ onNotFound: async () => {}, // yours: "did you mean…?"
367
+ run: (g, command, args, exec) => presence.during(sock, g.chatId, "composing", exec, { delayMs: 1200 }),
368
+ })
369
+ }
370
+ })
371
+ })
372
+
373
+ pool.on("connect", id => console.log(`${id} connected`))
374
+ pool.on("pairing-code", (id, code) => console.log(`${id}: enter ${code} on the phone`))
375
+ pool.on("logged-out", id => console.log(`${id} was logged out — pair again`))
376
+ pool.on("replaced", id => console.log(`${id} was opened somewhere else`))
377
+ pool.on("error", (id, e) => console.error(id, e))
378
+
379
+ pool.startAll([{ sessionId: "main", phoneNumber: process.env.BOT_NUMBER }])
380
+ ```
381
+
382
+ Suggested layout:
383
+
384
+ ```
385
+ my-bot/
386
+ index.js
387
+ commands/
388
+ user/ ping.js economy/balance.js …
389
+ admin/ ban.js close.js …
390
+ owner/ broadcast.js …
391
+ data/ sessions.sqlite bot.sqlite media-cache/ # keep out of git and backups
392
+ ```
393
+
394
+ ## Production notes
395
+
396
+ - **One process per session.** Opening the same session from two processes gets one of them kicked (`440` → `replaced`). Running several *different* numbers in one process is what the pool is for.
397
+ - **Back up `data/`** — `db.backup("backup.sqlite")` gives a consistent copy of the bot database while it runs. The session store holds credentials: treat it like a password.
398
+ - **Keep the media disk cache private.** It stores decrypted files; keep it out of backups and zips.
399
+ - **Spread out startup.** `pool.startAll(items, { intervalMs })` opens connections one after another rather than all at once.
400
+ - **Always give interactive messages a `fallback`.**
401
+ - **ffmpeg** must be on the `PATH` to send voice notes from non-Opus audio.
402
+ - **Node ≥ 22.5** is required by `store-sqlite` and `kv`. They print an `ExperimentalWarning` from Node itself; it is harmless.
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "morpho-socket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Blocos para bots de WhatsApp sobre o Baileys",
5
+ "license": "MIT",
6
+ "engines": { "node": ">=22.5.0" },
7
+ "peerDependencies": { "@whiskeysockets/baileys": "*" },
8
+ "files": ["packages", "README.md", "docs"],
9
+ "exports": {
10
+ "./core": "./packages/core/index.js",
11
+ "./store-sqlite": "./packages/store-sqlite/index.js",
12
+ "./store-file": "./packages/store-file/index.js",
13
+ "./pool": "./packages/pool/index.js",
14
+ "./message": "./packages/message/index.js",
15
+ "./jid": "./packages/jid/index.js",
16
+ "./groups": "./packages/groups/index.js",
17
+ "./media": "./packages/media/index.js",
18
+ "./reply": "./packages/reply/index.js",
19
+ "./interactive": "./packages/interactive/index.js",
20
+ "./presence": "./packages/presence/index.js",
21
+ "./sent": "./packages/sent/index.js",
22
+ "./context": "./packages/context/index.js",
23
+ "./commands": "./packages/commands/index.js",
24
+ "./kv": "./packages/kv/index.js"
25
+ },
26
+ "scripts": { "test": "node test/run-all.js" }
27
+ }
@@ -0,0 +1,78 @@
1
+ # morpho-socket/commands
2
+
3
+ The generic half of a command bot: **load commands from folders**, find them by name or alias, run them with the usual guards (owner / admin / bot-admin), and hook in everything that is your bot's own business (per-group switches, antiflood, "did you mean…", stats).
4
+
5
+ ```js
6
+ const path = require("path")
7
+ const { createCommands } = require("morpho-socket/commands")
8
+
9
+ const commands = createCommands({
10
+ dirs: [ // use absolute paths: each file is loaded with require()
11
+ { type: "user", dir: path.join(__dirname, "commands", "user") },
12
+ { type: "admin", dir: path.join(__dirname, "commands", "admin") },
13
+ { type: "owner", dir: path.join(__dirname, "commands", "owner") },
14
+ ],
15
+ log: (level, text) => console.log(level, text),
16
+ messages: { notAdmin: "You are not an admin here.", botNotAdmin: "I need to be an admin.", notOwner: "Owner only.", notGroup: "Groups only." },
17
+ })
18
+ commands.load() // warns about: repeated names/aliases, a mistyped "aliasee" key, broken files, commands without name/run
19
+
20
+ await commands.dispatch(g, { // the command comes from g.command (the group's prefix is already applied)
21
+ before: async (g, command, parsed) => { /* per-group switches, antiflood… return false to stop */ },
22
+ onNotFound: async (g, parsed) => { /* "unknown command" */ },
23
+ run: (g, command, args, exec) => presence.during(g.sock, g.chatId, "composing", exec, { delayMs: 1200 }),
24
+ })
25
+ ```
26
+
27
+ `dispatch` returns `true` when the text was a command call (found or not) and `false` when it wasn't, so you can route non-commands elsewhere.
28
+
29
+ ## A command
30
+
31
+ ```js
32
+ // commands/user/economy/balance.js
33
+ module.exports = {
34
+ name: "balance",
35
+ aliases: ["bal", "coins"],
36
+ access: "user", // "user" | "admin" | "owner" — optional, falls back to the folder's `type`
37
+ desc: "Shows your coins", // any extra field is kept (desc, usage, cooldown…) for your own use
38
+ async run(g, { name, args, argText }) {
39
+ await g.reply("You have 10 coins")
40
+ },
41
+ }
42
+ ```
43
+
44
+ - **Folders become categories:** `economy/balance.js` → `command.category = "economy"` (top-level files are `"general"`).
45
+ - Files starting with `_` and `*.test.js` are ignored (helpers and tests can live next to commands).
46
+ - A command's own `access` wins over the folder `type`.
47
+ - `admin` requires a group, an admin sender **and** an admin bot; `owner` requires `g.isOwner`. The texts are configurable through `messages` (`notGroup`, `botNotAdmin`, `notAdmin`, `notOwner`).
48
+ - One broken file never stops the others from loading.
49
+
50
+ ## Parsing text yourself
51
+
52
+ ```js
53
+ const { parse } = require("morpho-socket/commands")
54
+ parse("!Hi a b", "!") // { name: "hi", args: ["a", "b"], argText: "a b", prefix: "!" }
55
+
56
+ await commands.dispatch(g, { text: "!hi", prefix: "!" }) // parse something other than g.command
57
+ ```
58
+
59
+ ## Antiflood
60
+
61
+ More than N commands in X seconds → the person is ignored for Y seconds. The warning goes out **once per block**; everything else is silently ignored.
62
+
63
+ ```js
64
+ const { createAntiflood } = require("morpho-socket/commands")
65
+ const flood = createAntiflood()
66
+
67
+ // inside `before`:
68
+ const r = flood.check(`${g.clientId}:${g.user.number}`, { enabled: true, commands: 5, windowSec: 10, blockSec: 30 })
69
+ if (!r.ok) { if (r.warn) await g.reply(`Slow down — wait ${r.wait}s`); return false }
70
+ ```
71
+
72
+ `check` returns `{ ok, warn?, wait?, fresh? }`. Idle entries are cleaned up automatically.
73
+
74
+ ## API
75
+
76
+ `createCommands({ dirs, log, messages })` → `{ load, dispatch, parse, get(name), has(name), unique(), map, size }` — `unique()` is the real commands (the map holds every name *and* alias).
77
+
78
+ Also exported: `createAntiflood`, `parse`, `DEFAULT_MESSAGES`. `g` comes from [`context`](../context), but anything with `isOwner`, `isAdmin()`, `isBotAdmin()` and `reply()` works.