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
|
@@ -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.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Command limit per person: more than N commands in X seconds => ignored for Y seconds.
|
|
2
|
+
// The warning goes out ONCE per block (the bot doesn't spam); the rest is silently ignored.
|
|
3
|
+
function createAntiflood() {
|
|
4
|
+
const map = new Map() // key -> { marks, until, warned }
|
|
5
|
+
|
|
6
|
+
// cfg: { enabled, commands, windowSec, blockSec }. Returns { ok, warn, wait, fresh }
|
|
7
|
+
function check(key, cfg, now = Date.now()) {
|
|
8
|
+
if (!cfg.enabled) return { ok: true }
|
|
9
|
+
let e = map.get(key)
|
|
10
|
+
if (!e) { e = { marks: [], until: 0, warned: false }; map.set(key, e) }
|
|
11
|
+
|
|
12
|
+
if (e.until > now) {
|
|
13
|
+
const warn = !e.warned
|
|
14
|
+
e.warned = true
|
|
15
|
+
return { ok: false, warn, wait: Math.ceil((e.until - now) / 1000) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
e.warned = false
|
|
19
|
+
e.marks = e.marks.filter(t => now - t < cfg.windowSec * 1000)
|
|
20
|
+
e.marks.push(now)
|
|
21
|
+
if (e.marks.length > cfg.commands) {
|
|
22
|
+
e.until = now + cfg.blockSec * 1000
|
|
23
|
+
e.warned = true
|
|
24
|
+
e.marks = []
|
|
25
|
+
return { ok: false, warn: true, wait: cfg.blockSec, fresh: true }
|
|
26
|
+
}
|
|
27
|
+
return { ok: true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const timer = setInterval(() => {
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
for (const [k, e] of map) if (e.until < now && !e.marks.some(x => now - x < 300_000)) map.delete(k)
|
|
33
|
+
}, 600_000)
|
|
34
|
+
timer.unref?.()
|
|
35
|
+
|
|
36
|
+
return { check, size: () => map.size }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { createAntiflood }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @morpho/commands
|
|
2
|
+
//
|
|
3
|
+
// The generic half of a command bot: load commands from folders, find them by name/alias, parse "<prefix>name args",
|
|
4
|
+
// and run them with the usual guards (owner / admin / bot-admin) and hooks for everything that is the bot's own business
|
|
5
|
+
// (per-group enable/disable, antiflood, "did you mean", stats...).
|
|
6
|
+
//
|
|
7
|
+
// const commands = createCommands({ dirs: [{ dir }], log: console.log }) // (type = default access of that dir; default "user")
|
|
8
|
+
// commands.load()
|
|
9
|
+
// await commands.dispatch(g, { text, prefix, before, onNotFound, run })
|
|
10
|
+
//
|
|
11
|
+
// A command is { name, aliases?, access?: "user"|"admin"|"owner", run(g, { name, args, argText }), ...anything else (desc, usage...) }.
|
|
12
|
+
// Nested folders become categories: dir/economy/saldo.js -> command.category = "economy".
|
|
13
|
+
// `g` is a context from @morpho/context (or anything with isOwner / isAdmin() / isBotAdmin() / reply()).
|
|
14
|
+
const fs = require("fs")
|
|
15
|
+
const path = require("path")
|
|
16
|
+
const { createAntiflood } = require("./antiflood")
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MESSAGES = {
|
|
19
|
+
botNotAdmin: "❌ Eu não admin desse grupo.",
|
|
20
|
+
notAdmin: "❌ Você não é admin desse grupo.",
|
|
21
|
+
notOwner: "❌ Você é não dono.",
|
|
22
|
+
notGroup: "❌ Isso só funciona em grupo."
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// "<prefix>name a b c" -> { name: "name", args: ["a", "b", "c"], argText: "a b c", prefix } | null
|
|
26
|
+
// (the "g" from @morpho/context already does this: g.command. This is for plain text, or a g that has no .command)
|
|
27
|
+
function parse(text, prefix) {
|
|
28
|
+
if (!text || !prefix || !text.startsWith(prefix)) return null
|
|
29
|
+
const rest = text.slice(prefix.length).trim()
|
|
30
|
+
if (!rest) return null
|
|
31
|
+
const [raw, ...args] = rest.split(/ +/)
|
|
32
|
+
return { name: raw.toLowerCase(), args, argText: rest.slice(raw.length).trim(), prefix }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// opts: { dirs: [{ type, dir }], log(level, text) }
|
|
36
|
+
function createCommands({ dirs = [], log = () => {}, messages = {} } = {}) {
|
|
37
|
+
const map = new Map() // name or alias -> command
|
|
38
|
+
const texts = { ...DEFAULT_MESSAGES, ...messages }
|
|
39
|
+
|
|
40
|
+
const register = (name, command, file, dupes) => {
|
|
41
|
+
const old = map.get(name)
|
|
42
|
+
if (old && old !== command) dupes.push(`"${name}": ${old.file} and ${file}`)
|
|
43
|
+
map.set(name, command)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Folders can be nested: dir/economy/saldo.js -> category "economy". Files starting with "_" and "*.test.js" are skipped (helpers).
|
|
47
|
+
// Access: command.access ("user" | "admin" | "owner") wins; else the dir's `type`; else "user".
|
|
48
|
+
function walk(root, dir, out = []) {
|
|
49
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const full = path.join(dir, entry.name)
|
|
51
|
+
if (entry.isDirectory()) walk(root, full, out)
|
|
52
|
+
else if (entry.name.endsWith(".js") && !entry.name.startsWith("_") && !entry.name.endsWith(".test.js")) out.push(full)
|
|
53
|
+
}
|
|
54
|
+
return out.sort()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function load() {
|
|
58
|
+
map.clear()
|
|
59
|
+
const dupes = []
|
|
60
|
+
for (const { type = "user", dir } of dirs) {
|
|
61
|
+
if (!fs.existsSync(dir)) continue
|
|
62
|
+
for (const full of walk(dir, dir)) {
|
|
63
|
+
const rel = path.relative(dir, full), file = rel.split(path.sep).join("/")
|
|
64
|
+
let command
|
|
65
|
+
try { command = require(full) } // a broken file doesn't stop the others from loading
|
|
66
|
+
catch (e) { log("error", `Failed to load ${file}: ${e.message}`); continue }
|
|
67
|
+
if (!command?.name || typeof command?.run !== "function") { log("error", `Invalid command: ${file}`); continue }
|
|
68
|
+
|
|
69
|
+
// typo guard: "aliasee", "alias"... silently registered nothing before
|
|
70
|
+
for (const k of Object.keys(command)) if (/^alias/i.test(k) && k !== "aliases") log("warn", `${file}: unknown key "${k}" (did you mean "aliases"?)`)
|
|
71
|
+
|
|
72
|
+
command.access = command.type = command.access || type
|
|
73
|
+
command.category = command.category || (rel.includes(path.sep) ? rel.split(path.sep)[0] : "general")
|
|
74
|
+
command.file = file
|
|
75
|
+
register(command.name, command, file, dupes)
|
|
76
|
+
for (const alias of command.aliases || []) register(alias, command, file, dupes)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (dupes.length) log("warn", `Repeated names/aliases (the last one loaded wins):\n - ${dupes.join("\n - ")}`)
|
|
80
|
+
return map
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// the real commands (the Map holds every name AND alias)
|
|
84
|
+
const unique = () => [...new Set(map.values())]
|
|
85
|
+
|
|
86
|
+
// opts:
|
|
87
|
+
// before(g, command, parsed) -> false to stop (the hook answers by itself). Runs before the guards. (per-group switches, antiflood...)
|
|
88
|
+
// onNotFound(g, parsed) command doesn't exist
|
|
89
|
+
// run(g, command, args, exec) wraps the execution (e.g. presence "typing…"); exec() runs the command
|
|
90
|
+
// returns true when the text was a command call (found or not), false otherwise.
|
|
91
|
+
// The command comes from g.command (@morpho/context: the group's prefix is already applied). Pass { text, prefix } only
|
|
92
|
+
// to parse something else (or when your g has no .command).
|
|
93
|
+
async function dispatch(g, { text, prefix, before, onNotFound, run } = {}) {
|
|
94
|
+
const parsed = (text !== undefined || prefix !== undefined) ? parse(text, prefix) : (g.command ?? null)
|
|
95
|
+
if (!parsed) return false
|
|
96
|
+
const command = map.get(parsed.name)
|
|
97
|
+
if (!command) { await onNotFound?.(g, parsed); return true }
|
|
98
|
+
|
|
99
|
+
if (before && (await before(g, command, parsed)) === false) return true
|
|
100
|
+
|
|
101
|
+
if (command.type === "admin") {
|
|
102
|
+
if (g.isGroup === false) { await g.reply(texts.notGroup); return true } // admin commands are group commands
|
|
103
|
+
if (!(await g.isBotAdmin())) { await g.reply(texts.botNotAdmin); return true }
|
|
104
|
+
if (!(await g.isAdmin())) { await g.reply(texts.notAdmin); return true }
|
|
105
|
+
}
|
|
106
|
+
if (command.type === "owner" && !g.isOwner) { await g.reply(texts.notOwner); return true }
|
|
107
|
+
|
|
108
|
+
const exec = () => command.run(g, parsed)
|
|
109
|
+
await (run ? run(g, command, parsed.args, exec) : exec())
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { load, get: name => map.get(name), has: name => map.has(name), unique, dispatch, parse, map, get size() { return map.size } }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { createCommands, createAntiflood, parse, DEFAULT_MESSAGES }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/commands", "version": "0.1.0", "description": "Command registry (loader by folder, aliases, duplicate/typo warnings), prefix parsing, a dispatcher with owner/admin guards and hooks, and a per-person antiflood.", "main": "index.js", "license": "MIT" }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# morpho-socket/context
|
|
2
|
+
|
|
3
|
+
The **`g`** every command receives: one object with everything about the current message. One door per topic, the same mould everywhere, and no exceptions thrown for *expected* reasons (no permission, person not in the group…).
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const { createContext } = require("morpho-socket/context")
|
|
7
|
+
const { createIdentity } = require("morpho-socket/jid")
|
|
8
|
+
|
|
9
|
+
const contexts = createContext({
|
|
10
|
+
identity: createIdentity({ store }), // required
|
|
11
|
+
media, interactive, // optional: g.download*, buttons/lists
|
|
12
|
+
getGroupMeta: (sock, jid) => groups.get(jid), // default: sock.groupMetadata(jid)
|
|
13
|
+
isOwner: g => g.user.phone === "5511999999999", // default: only the bot's own device
|
|
14
|
+
protect: (g, person) => person.number === OWNER,// extra untouchables for g.kick / g.demote
|
|
15
|
+
prefix: "!", // default "/"
|
|
16
|
+
prefixFor: (g, jid) => prefixOf(jid), // per-group prefix, overrides `prefix`
|
|
17
|
+
names: { owner: "Owner", user: "Someone" }, // g.user.name: `owner` when the bot's own device sends, `user` when there is no push name
|
|
18
|
+
extend: { // YOUR bot's own getters and methods
|
|
19
|
+
get vip() { return this.client?.vip },
|
|
20
|
+
shout(text) { return this.reply(text.toUpperCase()) },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const g = await contexts.build({ sock, msg, clientId, anything: "else" }) // extra props are copied onto g
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`build()` resolves the sender once and never throws.
|
|
28
|
+
|
|
29
|
+
## The message and who sent it
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| message | `g.text` `g.type` `g.id` `g.chatId` `g.isGroup` `g.isPrivate` `g.isFromMe` `g.timestamp` `g.quoted` `g.mentions` `g.is("image", …)` |
|
|
34
|
+
| command | `g.command` → `{ prefix, name, args, argText }` or `null` · shortcuts `g.args` `g.argText` `g.prefix` |
|
|
35
|
+
| sender | `g.user` → `{ jid, lid, phone, hasPhone, number, formatted, source, name }` — `number` is never empty (the phone, or the LID digits): it is the key to use in your database |
|
|
36
|
+
| permissions | `g.isOwner` · `await g.isAdmin()` · `await g.isBotAdmin()` |
|
|
37
|
+
| raw | `g.msg` `g.sock` `g.clientId` `g.parsed` (the [`message`](../message) object) `g.identity` |
|
|
38
|
+
|
|
39
|
+
A tap on one of **your** buttons becomes `g.text` (e.g. `"menu games"`). A tap on another bot's button is ignored.
|
|
40
|
+
|
|
41
|
+
## People
|
|
42
|
+
|
|
43
|
+
`await g.who(x?)` returns a **Person** or `null`: `{ jid, lid, phone, number, admin, superadmin, bot }`.
|
|
44
|
+
|
|
45
|
+
With no argument: the mentioned person, or the author of the replied-to message. With an argument: a number, a JID, a `@lid`, or another Person.
|
|
46
|
+
|
|
47
|
+
## Groups
|
|
48
|
+
|
|
49
|
+
`await g.group()` → `{ id, name, description, owner, size, announce, locked, approval, createdAt }` · `await g.members()` · `await g.admins()`
|
|
50
|
+
|
|
51
|
+
**Actions** all share one mould. They never throw; they return `{ ok, action, reason?, jid?, number? }`. Several at once: `g.kick([a, b])` → `{ ok, results }`, **one** WhatsApp call. Morpho never writes text for the user — your bot translates `reason`.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const r = await g.kick() // or g.kick("5511999999999") / jid / Person
|
|
55
|
+
if (!r.ok) return g.reply(messages[r.reason] ?? messages.failed)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| people | `kick` `promote` `demote` `add(number)` `approve` `reject` |
|
|
61
|
+
| tag everyone | `tagAll(text, { quote, admins, bot })` — hidden mention: nobody sees a list of numbers, everybody is notified → `{ ok, action, count, sent }` |
|
|
62
|
+
| group | `close()` `open()` `lock()` `unlock()` `rename(text)` `setDescription(text)` `leave()` |
|
|
63
|
+
| invite link | `link()` · `revokeLink()` → `{ code, url }` |
|
|
64
|
+
| message | `delete()` removes the replied-to message (or the command itself) · `delete("command")` · `delete(sentMessage)` |
|
|
65
|
+
|
|
66
|
+
Safe by default: `kick` and `demote` don't touch the bot, the group creator or admins (`kick(x, { admins: true })` allows admins). `add` returns `needs-invite` (with `invite`) when the person's privacy settings require an invite link.
|
|
67
|
+
|
|
68
|
+
**`reason` codes** (also exported as `GROUP_REASONS`): `not-group` · `bot-not-admin` · `no-target` · `invalid-user` · `not-in-group` · `already-in-group` · `already-admin` · `not-admin` · `target-is-bot` · `target-is-creator` · `target-is-admin` · `protected` · `not-allowed` · `needs-invite` · `not-on-whatsapp` · `recently-left` · `invalid-text` · `rate-limited` · `failed`
|
|
69
|
+
|
|
70
|
+
## Sending: one door
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
await g.reply("hi") // quotes the command, keeps mentions
|
|
74
|
+
await g.reply({ image: buf, caption: "look" }) // image | video | gif | sticker | voice | audio | document
|
|
75
|
+
await g.reply({ body: "Pick one", buttons, fallback: "plain text" }) // buttons/list; falls back to text on failure
|
|
76
|
+
await g.send(jid, "hi") // another chat (quotes only with { quote: true })
|
|
77
|
+
await g.reply(`congrats ${await g.at(person)}!`) // g.at() writes "@id" AND registers the mention
|
|
78
|
+
|
|
79
|
+
const sent = await g.reply("calculating…")
|
|
80
|
+
await g.edit(sent, "done!"); await g.delete(sent)
|
|
81
|
+
await g.react("🎉"); await g.poll("Pizza?", ["yes", "no"])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Media sources: `Buffer` · `"https://…"` · `"/path/to/file"` · `{ url }` (sticker and voice need a Buffer or a file). Options (second argument, or inside the object): `mentions` (anything `g.at` understands), `quote: false | true | msg`, `mimetype` / `fileName`.
|
|
85
|
+
|
|
86
|
+
**Read more:** `g.readMore` is a string; put it between two parts of a text and WhatsApp folds the second part behind "Read more…": ``await g.reply(`Title${g.readMore}Long text`)``.
|
|
87
|
+
|
|
88
|
+
Everything returns the sent message (with `.key`). If quoting fails (e.g. the command came from a button tap) it sends without the quote; if the media fails and there is a caption, it sends the caption. **A command is never left mute.**
|
|
89
|
+
|
|
90
|
+
## Incoming media
|
|
91
|
+
|
|
92
|
+
`g.media()` looks without downloading (`type`, `seconds`, `size`…) · `g.hasMedia` · `await g.download()` → `Buffer | null` · `await g.downloadInfo()` → `{ buffer, type, mimetype, … }`.
|
|
93
|
+
|
|
94
|
+
The **replied-to** media wins over the message's own. Options: `{ types, max, quoted: "prefer" | "only" | "never", viewOnce }` — view-once media is only returned with `viewOnce: true`, so nobody can reveal it by accident. Errors carry `.code` (`MEDIA_TOO_LARGE`, `MEDIA_EXPIRED`…). Caching, retries and expired links are [`media`](../media)'s job.
|
|
95
|
+
|
|
96
|
+
## Extending `g`
|
|
97
|
+
|
|
98
|
+
`extend` takes an object whose **getters stay getters**; inside them `this` is the `g`. That is where your rules live — plans, per-group settings, helpers — while the generic parts stay in Morpho.
|
|
99
|
+
|
|
100
|
+
Also exported: `participantIds`, `isAdminRole`, `GROUP_REASONS`, `digits`.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// @morpho/context/group — group actions on the "g": g.kick(user), g.promote(user), g.close() ...
|
|
2
|
+
//
|
|
3
|
+
// One mould for everything:
|
|
4
|
+
// const r = await g.kick(@mention | "5511999999999" | jid | g.who() | nothing) // nothing = mentioned / replied person
|
|
5
|
+
// r -> { ok: true, action, jid, number }
|
|
6
|
+
// { ok: false, action, reason, jid?, number?, error? } // it NEVER throws
|
|
7
|
+
// g.kick([a, b, c]) -> { ok, action, results: [r, r, r] } // several at once, one WhatsApp call
|
|
8
|
+
//
|
|
9
|
+
// `reason` is a short stable code (see REASONS) — the bot decides what to say; Morpho never writes user-facing text.
|
|
10
|
+
// Safe by default: admins, the group creator and the bot itself are protected (kick/demote). Pass { admins: true }
|
|
11
|
+
// to allow kicking an admin. `createContext({ protect: (g, info) => bool })` adds your own untouchables (the bot owner...).
|
|
12
|
+
const { normalize, digits } = require("../jid")
|
|
13
|
+
const { userPart, participantIds, isAdminRole, botIds } = require("./participants")
|
|
14
|
+
|
|
15
|
+
const REASONS = Object.freeze([
|
|
16
|
+
"not-group", // used outside a group
|
|
17
|
+
"bot-not-admin", // the bot needs to be admin for this
|
|
18
|
+
"no-target", // nobody mentioned / replied to and nothing was passed
|
|
19
|
+
"invalid-user", // could not understand who it is
|
|
20
|
+
"not-in-group", // that person is not in the group
|
|
21
|
+
"already-in-group", // add: already a member
|
|
22
|
+
"already-admin", // promote: already admin
|
|
23
|
+
"not-admin", // demote: is not admin
|
|
24
|
+
"target-is-bot", // never acts on the bot itself
|
|
25
|
+
"target-is-creator", // the group creator can't be touched
|
|
26
|
+
"target-is-admin", // kick: admins are protected unless { admins: true }
|
|
27
|
+
"protected", // your own protect() said no
|
|
28
|
+
"not-allowed", // WhatsApp said 403
|
|
29
|
+
"needs-invite", // add: privacy settings — send the invite link instead (result.invite)
|
|
30
|
+
"not-on-whatsapp", // add: that number has no WhatsApp
|
|
31
|
+
"recently-left", // add: left recently, can't be re-added yet
|
|
32
|
+
"invalid-text", // rename/description: empty or too long
|
|
33
|
+
"rate-limited", // WhatsApp asked us to slow down
|
|
34
|
+
"failed" // anything else (result.error has the message)
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const fail = (action, reason, extra) => ({ ok: false, action, reason, ...extra })
|
|
38
|
+
const success = (action, extra) => ({ ok: true, action, ...extra })
|
|
39
|
+
|
|
40
|
+
function reasonFromError(e) {
|
|
41
|
+
const code = Number(e?.output?.statusCode ?? e?.data ?? e?.status ?? e?.statusCode)
|
|
42
|
+
const msg = String(e?.message || e || "")
|
|
43
|
+
if (/not-authorized/i.test(msg) || code === 401) return "bot-not-admin"
|
|
44
|
+
if (/forbidden/i.test(msg) || code === 403) return "not-allowed"
|
|
45
|
+
if (/item-not-found/i.test(msg) || code === 404) return "not-in-group"
|
|
46
|
+
if (/rate-overlimit/i.test(msg) || code === 429) return "rate-limited"
|
|
47
|
+
return "failed"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// per-participant status of groupParticipantsUpdate ("200", "403"...) -> reason (null = ok)
|
|
51
|
+
function reasonFromStatus(status, op) {
|
|
52
|
+
const s = String(status ?? "200")
|
|
53
|
+
if (s === "200") return null
|
|
54
|
+
if (s === "403") return op === "add" ? "needs-invite" : "not-allowed"
|
|
55
|
+
if (s === "404") return op === "add" ? "not-on-whatsapp" : "not-in-group"
|
|
56
|
+
if (s === "408") return "recently-left"
|
|
57
|
+
if (s === "409") return "already-in-group"
|
|
58
|
+
if (s === "401") return "not-allowed"
|
|
59
|
+
if (s === "429") return "rate-limited"
|
|
60
|
+
return "failed"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createGroupActions({ identity, protect } = {}) {
|
|
64
|
+
async function guard(g, action, needBotAdmin = true) {
|
|
65
|
+
if (!g.isGroup) return fail(action, "not-group")
|
|
66
|
+
try { if (needBotAdmin && !(await g.isBotAdmin())) return fail(action, "bot-not-admin") }
|
|
67
|
+
catch (e) { return fail(action, "failed", { error: e?.message }) }
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Person: { jid, lid, phone, number, admin, superadmin, bot }. number never empty (phone, else the LID digits).
|
|
72
|
+
// resolve=false skips the (possible) identity lookup, for listing many members cheaply.
|
|
73
|
+
async function describe(g, p, resolve = true) {
|
|
74
|
+
const me = botIds(g.sock)
|
|
75
|
+
const id = String(p.id)
|
|
76
|
+
const lid = p.lid || (id.endsWith("@lid") ? id : null)
|
|
77
|
+
let phone = p.phoneNumber ? userPart(p.phoneNumber) : (id.endsWith("@s.whatsapp.net") ? userPart(id) : null)
|
|
78
|
+
if (!phone && resolve) { try { phone = (await identity.resolveJid(p.id, { sock: g.sock, clientId: g.clientId, group: g.chatId }))?.number || null } catch {} }
|
|
79
|
+
return { jid: p.id, lid, phone, number: phone || userPart(id), admin: isAdminRole(p), superadmin: p.admin === "superadmin", bot: participantIds(p).some(x => me.includes(x)) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// one call for one or many; resolve(x) -> { error, jid?, number? } | { jid, number?, info? }; check(info, opts) -> reason | null
|
|
83
|
+
async function run(g, action, op, input, opts, { resolve, check, call }) {
|
|
84
|
+
const many = Array.isArray(input)
|
|
85
|
+
const list = many ? input : [input]
|
|
86
|
+
const blocked = await guard(g, action)
|
|
87
|
+
const out = new Array(list.length)
|
|
88
|
+
if (blocked) { list.forEach((_, i) => { out[i] = blocked }); return many ? { ok: false, action, results: out } : blocked }
|
|
89
|
+
|
|
90
|
+
const valid = []
|
|
91
|
+
for (let i = 0; i < list.length; i++) {
|
|
92
|
+
let r
|
|
93
|
+
try { r = await resolve(list[i]) } catch (e) { r = { error: "failed", message: e?.message } }
|
|
94
|
+
if (r.error) { out[i] = fail(action, r.error, { jid: r.jid, number: r.number, error: r.message }); continue }
|
|
95
|
+
const why = check ? await check(r.info, opts) : null
|
|
96
|
+
if (why) { out[i] = fail(action, why, { jid: r.jid, number: r.number }); continue }
|
|
97
|
+
valid.push({ i, jid: r.jid, number: r.number })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (valid.length) {
|
|
101
|
+
let res = null, thrown = null
|
|
102
|
+
try { res = await call(valid.map(v => v.jid)) } catch (e) { thrown = e }
|
|
103
|
+
const byJid = new Map((Array.isArray(res) ? res : []).map(x => [normalize(x?.jid), x]))
|
|
104
|
+
for (const v of valid) {
|
|
105
|
+
if (thrown) { out[v.i] = fail(action, reasonFromError(thrown), { jid: v.jid, number: v.number, error: thrown?.message }); continue }
|
|
106
|
+
const st = byJid.get(normalize(v.jid))
|
|
107
|
+
const why = reasonFromStatus(st?.status, op)
|
|
108
|
+
out[v.i] = why ? fail(action, why, { jid: v.jid, number: v.number, ...(why === "needs-invite" && st?.content ? { invite: st.content } : {}) })
|
|
109
|
+
: success(action, { jid: v.jid, number: v.number })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return many ? { ok: out.every(r => r.ok), action, results: out } : out[0]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// single-shot setting/info calls
|
|
116
|
+
async function simple(g, action, { needBotAdmin = true, call, map }) {
|
|
117
|
+
const blocked = await guard(g, action, needBotAdmin)
|
|
118
|
+
if (blocked) return blocked
|
|
119
|
+
try { const v = await call(); return success(action, map ? map(v) : {}) }
|
|
120
|
+
catch (e) { return fail(action, reasonFromError(e), { error: e?.message }) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const inGroup = async (g, x) => { // resolveUser wrapper -> run()'s resolve shape
|
|
124
|
+
const r = await g.resolveUser(x)
|
|
125
|
+
return r.error ? { error: r.error, jid: r.jid, number: r.number } : { jid: r.info.jid, number: r.info.number, info: r.info }
|
|
126
|
+
}
|
|
127
|
+
const outsider = async x => { // for add/approve/reject: people who are NOT members yet
|
|
128
|
+
const raw = typeof x === "string" ? x : (x?.id || x?.jid || x?.number || x?.pn)
|
|
129
|
+
const d = typeof raw === "string" && !raw.endsWith("@lid") ? digits(raw) : null
|
|
130
|
+
if (d) return { jid: `${d}@s.whatsapp.net`, number: d }
|
|
131
|
+
if (typeof raw === "string" && raw.endsWith("@lid")) return { jid: normalize(raw), number: null }
|
|
132
|
+
return { error: "invalid-user" }
|
|
133
|
+
}
|
|
134
|
+
const protectCheck = async (g, info) => (protect && await protect(g, info)) ? "protected" : null
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
// -> { info } | { error, jid?, number? }. info: { id, jid, number, admin, superadmin, bot }
|
|
138
|
+
async resolveUser(input) {
|
|
139
|
+
if (input === undefined || input === null) input = this.mentions[0] || this.parsed.context?.participant // mentioned, else the replied person
|
|
140
|
+
if (!input) return { error: "no-target" }
|
|
141
|
+
const raw = typeof input === "string" ? input : (input.jid || input.lid || (input.phone && `${input.phone}@s.whatsapp.net`) || input.number)
|
|
142
|
+
if (typeof raw !== "string") return { error: "invalid-user" }
|
|
143
|
+
const want = raw.includes("@") ? normalize(raw) : (digits(raw) ? `${digits(raw)}@s.whatsapp.net` : null)
|
|
144
|
+
if (!want) return { error: "invalid-user" }
|
|
145
|
+
const meta = await this.groupMeta()
|
|
146
|
+
const p = meta?.participants?.find(p => participantIds(p).includes(want))
|
|
147
|
+
if (!p) return { error: "not-in-group", jid: want, number: digits(want) }
|
|
148
|
+
return { info: await describe(this, p) }
|
|
149
|
+
},
|
|
150
|
+
// the Person (see above) or null: g.who() = mentioned/replied; g.who("5511...") / g.who(jid) / g.who(person)
|
|
151
|
+
async who(input) { return (await this.resolveUser(input)).info || null },
|
|
152
|
+
|
|
153
|
+
// ----- group info -----
|
|
154
|
+
async group() {
|
|
155
|
+
if (!this.isGroup) return null
|
|
156
|
+
const m = await this.groupMeta()
|
|
157
|
+
if (!m) return null
|
|
158
|
+
return { id: this.chatId, name: m.subject || "", description: m.desc || null, owner: m.owner || null, size: m.participants?.length ?? m.size ?? 0,
|
|
159
|
+
announce: !!m.announce, locked: !!m.restrict, approval: !!m.joinApprovalMode, createdAt: m.creation ? m.creation * 1000 : null }
|
|
160
|
+
},
|
|
161
|
+
async members() { return Promise.all(((await this.groupMeta())?.participants || []).map(p => describe(this, p, false))) },
|
|
162
|
+
async admins() { return (await this.members()).filter(m => m.admin) },
|
|
163
|
+
|
|
164
|
+
// ----- tag everyone (hidden: the text shows no @numbers, but everybody is notified) -----
|
|
165
|
+
// await g.tagAll("Attention, everyone!") // quotes the command by default
|
|
166
|
+
// await g.tagAll("Hi", { quote: false, admins: true }) // admins: only tag the admins
|
|
167
|
+
// -> { ok: true, action, count, sent } | { ok: false, action, reason }
|
|
168
|
+
async tagAll(text, opts = {}) {
|
|
169
|
+
const blocked = await guard(this, "tagAll", false)
|
|
170
|
+
if (blocked) return blocked
|
|
171
|
+
const t = String(text ?? "")
|
|
172
|
+
if (!t.trim()) return fail("tagAll", "invalid-text")
|
|
173
|
+
try {
|
|
174
|
+
const meta = await this.groupMeta()
|
|
175
|
+
const me = botIds(this.sock)
|
|
176
|
+
const jids = (meta?.participants || [])
|
|
177
|
+
.filter(p => (!opts.admins || isAdminRole(p)) && !(opts.bot === false && participantIds(p).some(x => me.includes(x))))
|
|
178
|
+
.map(p => p.id)
|
|
179
|
+
const content = { text: t, mentions: jids }
|
|
180
|
+
const quoted = opts.quote === false ? null : (opts.quote && opts.quote !== true ? opts.quote : this.msg)
|
|
181
|
+
let sent
|
|
182
|
+
try { sent = await this.sock.sendMessage(this.chatId, content, quoted ? { quoted } : undefined) }
|
|
183
|
+
catch (e) { if (!quoted) throw e; sent = await this.sock.sendMessage(this.chatId, content) } // quote failed (button tap...)? send loose
|
|
184
|
+
return success("tagAll", { count: jids.length, sent })
|
|
185
|
+
} catch (e) { return fail("tagAll", reasonFromError(e), { error: e?.message }) }
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
kick(user, opts = {}) {
|
|
189
|
+
return run(this, "kick", "remove", user, opts, {
|
|
190
|
+
resolve: x => inGroup(this, x),
|
|
191
|
+
check: async (info, o) => info.bot ? "target-is-bot" : info.superadmin ? "target-is-creator"
|
|
192
|
+
: (info.admin && !o.admins) ? "target-is-admin" : protectCheck(this, info),
|
|
193
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "remove")
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
promote(user, opts = {}) {
|
|
197
|
+
return run(this, "promote", "promote", user, opts, {
|
|
198
|
+
resolve: x => inGroup(this, x),
|
|
199
|
+
check: info => info.bot ? "target-is-bot" : info.admin ? "already-admin" : null,
|
|
200
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "promote")
|
|
201
|
+
})
|
|
202
|
+
},
|
|
203
|
+
demote(user, opts = {}) {
|
|
204
|
+
return run(this, "demote", "demote", user, opts, {
|
|
205
|
+
resolve: x => inGroup(this, x),
|
|
206
|
+
check: async info => info.bot ? "target-is-bot" : info.superadmin ? "target-is-creator" : !info.admin ? "not-admin" : protectCheck(this, info),
|
|
207
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "demote")
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
// add("5511999999999") / add([..]) — numbers or phone JIDs
|
|
211
|
+
add(number, opts = {}) {
|
|
212
|
+
return run(this, "add", "add", number, opts, {
|
|
213
|
+
resolve: outsider,
|
|
214
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "add")
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
// join requests (groups with approval)
|
|
218
|
+
approve(user, opts = {}) {
|
|
219
|
+
return run(this, "approve", "approve", user, opts, { resolve: outsider, call: jids => this.sock.groupRequestParticipantsUpdate(this.chatId, jids, "approve") })
|
|
220
|
+
},
|
|
221
|
+
reject(user, opts = {}) {
|
|
222
|
+
return run(this, "reject", "reject", user, opts, { resolve: outsider, call: jids => this.sock.groupRequestParticipantsUpdate(this.chatId, jids, "reject") })
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// ----- group settings -----
|
|
226
|
+
rename(subject) {
|
|
227
|
+
const t = String(subject ?? "").trim()
|
|
228
|
+
if (!t || t.length > 100) return Promise.resolve(fail("rename", this.isGroup ? "invalid-text" : "not-group"))
|
|
229
|
+
return simple(this, "rename", { needBotAdmin: false, call: () => this.sock.groupUpdateSubject(this.chatId, t) })
|
|
230
|
+
},
|
|
231
|
+
setDescription(text) {
|
|
232
|
+
const t = String(text ?? "")
|
|
233
|
+
if (t.length > 2048) return Promise.resolve(fail("setDescription", this.isGroup ? "invalid-text" : "not-group"))
|
|
234
|
+
return simple(this, "setDescription", { needBotAdmin: false, call: () => this.sock.groupUpdateDescription(this.chatId, t || undefined) })
|
|
235
|
+
},
|
|
236
|
+
close() { return simple(this, "close", { call: () => this.sock.groupSettingUpdate(this.chatId, "announcement") }) }, // only admins talk
|
|
237
|
+
open() { return simple(this, "open", { call: () => this.sock.groupSettingUpdate(this.chatId, "not_announcement") }) }, // everyone talks
|
|
238
|
+
lock() { return simple(this, "lock", { call: () => this.sock.groupSettingUpdate(this.chatId, "locked") }) }, // only admins edit group info
|
|
239
|
+
unlock() { return simple(this, "unlock", { call: () => this.sock.groupSettingUpdate(this.chatId, "unlocked") }) },
|
|
240
|
+
|
|
241
|
+
// ----- invite link -----
|
|
242
|
+
link() { return simple(this, "link", { call: () => this.sock.groupInviteCode(this.chatId), map: code => ({ code, url: `https://chat.whatsapp.com/${code}` }) }) },
|
|
243
|
+
revokeLink() { return simple(this, "revokeLink", { call: () => this.sock.groupRevokeInvite(this.chatId), map: code => ({ code, url: `https://chat.whatsapp.com/${code}` }) }) },
|
|
244
|
+
leave() { return simple(this, "leave", { needBotAdmin: false, call: () => this.sock.groupLeave(this.chatId) }) },
|
|
245
|
+
|
|
246
|
+
// ----- delete a message for everyone -----
|
|
247
|
+
// g.delete() -> the replied message (or the command itself if nothing was replied to)
|
|
248
|
+
// g.delete("command") -> the command message
|
|
249
|
+
// g.delete(sentResult) -> a message the bot sent (its { key }) or any Baileys key
|
|
250
|
+
async delete(target) {
|
|
251
|
+
let key
|
|
252
|
+
if (target && typeof target === "object") key = target.key || target
|
|
253
|
+
else if (target === "command" || !this.quoted) key = this.msg?.key
|
|
254
|
+
else {
|
|
255
|
+
const c = this.parsed.context || {}
|
|
256
|
+
const who = c.participant || null
|
|
257
|
+
key = { remoteJid: this.chatId, id: c.stanzaId, fromMe: !!who && botIds(this.sock).includes(normalize(who)), participant: who || undefined }
|
|
258
|
+
}
|
|
259
|
+
if (!key?.id) return fail("delete", "invalid-user")
|
|
260
|
+
const own = !!key.fromMe
|
|
261
|
+
const blocked = await guard(this, "delete", this.isGroup && !own)
|
|
262
|
+
if (blocked && blocked.reason !== "not-group") return blocked
|
|
263
|
+
try { await this.sock.sendMessage(this.chatId, { delete: { ...key, remoteJid: key.remoteJid || this.chatId } }); return success("delete", { key }) }
|
|
264
|
+
catch (e) { return fail("delete", reasonFromError(e), { error: e?.message }) }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { createGroupActions, REASONS, reasonFromError, reasonFromStatus }
|