morpho-socket 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -2
- package/docs/building-a-bot.md +402 -0
- package/package.json +35 -4
- package/packages/commands/README.md +78 -0
- package/packages/commands/antiflood.js +39 -0
- package/packages/commands/index.js +116 -0
- package/packages/commands/package.json +1 -0
- package/packages/context/README.md +100 -0
- package/packages/context/group.js +269 -0
- package/packages/context/index.js +132 -0
- package/packages/context/package.json +1 -0
- package/packages/context/participants.js +12 -0
- package/packages/context/send.js +127 -0
- package/packages/core/README.md +50 -0
- package/packages/core/baileys.js +25 -0
- package/packages/core/codec.js +17 -0
- package/packages/core/index.js +86 -0
- package/packages/core/package.json +7 -0
- package/packages/core/store.js +11 -0
- package/packages/groups/README.md +42 -0
- package/packages/groups/index.js +205 -0
- package/packages/groups/package.json +7 -0
- package/packages/interactive/README.md +41 -0
- package/packages/interactive/index.js +92 -0
- package/packages/interactive/package.json +1 -0
- package/packages/jid/README.md +51 -0
- package/packages/jid/index.js +285 -0
- package/packages/jid/package.json +7 -0
- package/packages/kv/README.md +45 -0
- package/packages/kv/index.js +164 -0
- package/packages/kv/package.json +8 -0
- package/packages/media/README.md +70 -0
- package/packages/media/cache.js +116 -0
- package/packages/media/erros.js +45 -0
- package/packages/media/find.js +51 -0
- package/packages/media/index.js +261 -0
- package/packages/media/package.json +8 -0
- package/packages/message/README.md +53 -0
- package/packages/message/index.js +317 -0
- package/packages/message/package.json +7 -0
- package/packages/pool/README.md +75 -0
- package/packages/pool/index.js +369 -0
- package/packages/pool/package.json +8 -0
- package/packages/presence/README.md +40 -0
- package/packages/presence/index.js +80 -0
- package/packages/presence/package.json +1 -0
- package/packages/reply/README.md +36 -0
- package/packages/reply/index.js +126 -0
- package/packages/reply/package.json +7 -0
- package/packages/reply/voice.js +32 -0
- package/packages/sent/README.md +27 -0
- package/packages/sent/index.js +65 -0
- package/packages/sent/package.json +1 -0
- package/packages/store-file/README.md +28 -0
- package/packages/store-file/index.js +63 -0
- package/packages/store-file/package.json +8 -0
- package/packages/store-sqlite/README.md +25 -0
- package/packages/store-sqlite/index.js +98 -0
- package/packages/store-sqlite/package.json +9 -0
package/README.md
CHANGED
|
@@ -1,3 +1,156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# morpho-socket
|
|
2
2
|
|
|
3
|
-
|
|
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()` · `g.tagAll(text)` · … (`g.readMore` = the "Read more…" fold) |
|
|
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,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "morpho-socket",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Blocos para bots de WhatsApp sobre o Baileys",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5.0"
|
|
8
|
+
},
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@whiskeysockets/baileys": "*"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"packages",
|
|
14
|
+
"README.md",
|
|
15
|
+
"docs"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
"./core": "./packages/core/index.js",
|
|
19
|
+
"./store-sqlite": "./packages/store-sqlite/index.js",
|
|
20
|
+
"./store-file": "./packages/store-file/index.js",
|
|
21
|
+
"./pool": "./packages/pool/index.js",
|
|
22
|
+
"./message": "./packages/message/index.js",
|
|
23
|
+
"./jid": "./packages/jid/index.js",
|
|
24
|
+
"./groups": "./packages/groups/index.js",
|
|
25
|
+
"./media": "./packages/media/index.js",
|
|
26
|
+
"./reply": "./packages/reply/index.js",
|
|
27
|
+
"./interactive": "./packages/interactive/index.js",
|
|
28
|
+
"./presence": "./packages/presence/index.js",
|
|
29
|
+
"./sent": "./packages/sent/index.js",
|
|
30
|
+
"./context": "./packages/context/index.js",
|
|
31
|
+
"./commands": "./packages/commands/index.js",
|
|
32
|
+
"./kv": "./packages/kv/index.js"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node test/run-all.js"
|
|
36
|
+
}
|
|
37
|
+
}
|