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.
- package/README.md +155 -2
- package/docs/building-a-bot.md +402 -0
- package/package.json +25 -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 +97 -0
- package/packages/context/group.js +245 -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,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,97 @@
|
|
|
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
|
+
| group | `close()` `open()` `lock()` `unlock()` `rename(text)` `setDescription(text)` `leave()` |
|
|
62
|
+
| invite link | `link()` · `revokeLink()` → `{ code, url }` |
|
|
63
|
+
| message | `delete()` removes the replied-to message (or the command itself) · `delete("command")` · `delete(sentMessage)` |
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
**`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`
|
|
68
|
+
|
|
69
|
+
## Sending: one door
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
await g.reply("hi") // quotes the command, keeps mentions
|
|
73
|
+
await g.reply({ image: buf, caption: "look" }) // image | video | gif | sticker | voice | audio | document
|
|
74
|
+
await g.reply({ body: "Pick one", buttons, fallback: "plain text" }) // buttons/list; falls back to text on failure
|
|
75
|
+
await g.send(jid, "hi") // another chat (quotes only with { quote: true })
|
|
76
|
+
await g.reply(`congrats ${await g.at(person)}!`) // g.at() writes "@id" AND registers the mention
|
|
77
|
+
|
|
78
|
+
const sent = await g.reply("calculating…")
|
|
79
|
+
await g.edit(sent, "done!"); await g.delete(sent)
|
|
80
|
+
await g.react("🎉"); await g.poll("Pizza?", ["yes", "no"])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
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`.
|
|
84
|
+
|
|
85
|
+
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.**
|
|
86
|
+
|
|
87
|
+
## Incoming media
|
|
88
|
+
|
|
89
|
+
`g.media()` looks without downloading (`type`, `seconds`, `size`…) · `g.hasMedia` · `await g.download()` → `Buffer | null` · `await g.downloadInfo()` → `{ buffer, type, mimetype, … }`.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
## Extending `g`
|
|
94
|
+
|
|
95
|
+
`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.
|
|
96
|
+
|
|
97
|
+
Also exported: `participantIds`, `isAdminRole`, `GROUP_REASONS`, `digits`.
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
kick(user, opts = {}) {
|
|
165
|
+
return run(this, "kick", "remove", user, opts, {
|
|
166
|
+
resolve: x => inGroup(this, x),
|
|
167
|
+
check: async (info, o) => info.bot ? "target-is-bot" : info.superadmin ? "target-is-creator"
|
|
168
|
+
: (info.admin && !o.admins) ? "target-is-admin" : protectCheck(this, info),
|
|
169
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "remove")
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
promote(user, opts = {}) {
|
|
173
|
+
return run(this, "promote", "promote", user, opts, {
|
|
174
|
+
resolve: x => inGroup(this, x),
|
|
175
|
+
check: info => info.bot ? "target-is-bot" : info.admin ? "already-admin" : null,
|
|
176
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "promote")
|
|
177
|
+
})
|
|
178
|
+
},
|
|
179
|
+
demote(user, opts = {}) {
|
|
180
|
+
return run(this, "demote", "demote", user, opts, {
|
|
181
|
+
resolve: x => inGroup(this, x),
|
|
182
|
+
check: async info => info.bot ? "target-is-bot" : info.superadmin ? "target-is-creator" : !info.admin ? "not-admin" : protectCheck(this, info),
|
|
183
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "demote")
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
// add("5511999999999") / add([..]) — numbers or phone JIDs
|
|
187
|
+
add(number, opts = {}) {
|
|
188
|
+
return run(this, "add", "add", number, opts, {
|
|
189
|
+
resolve: outsider,
|
|
190
|
+
call: jids => this.sock.groupParticipantsUpdate(this.chatId, jids, "add")
|
|
191
|
+
})
|
|
192
|
+
},
|
|
193
|
+
// join requests (groups with approval)
|
|
194
|
+
approve(user, opts = {}) {
|
|
195
|
+
return run(this, "approve", "approve", user, opts, { resolve: outsider, call: jids => this.sock.groupRequestParticipantsUpdate(this.chatId, jids, "approve") })
|
|
196
|
+
},
|
|
197
|
+
reject(user, opts = {}) {
|
|
198
|
+
return run(this, "reject", "reject", user, opts, { resolve: outsider, call: jids => this.sock.groupRequestParticipantsUpdate(this.chatId, jids, "reject") })
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ----- group settings -----
|
|
202
|
+
rename(subject) {
|
|
203
|
+
const t = String(subject ?? "").trim()
|
|
204
|
+
if (!t || t.length > 100) return Promise.resolve(fail("rename", this.isGroup ? "invalid-text" : "not-group"))
|
|
205
|
+
return simple(this, "rename", { needBotAdmin: false, call: () => this.sock.groupUpdateSubject(this.chatId, t) })
|
|
206
|
+
},
|
|
207
|
+
setDescription(text) {
|
|
208
|
+
const t = String(text ?? "")
|
|
209
|
+
if (t.length > 2048) return Promise.resolve(fail("setDescription", this.isGroup ? "invalid-text" : "not-group"))
|
|
210
|
+
return simple(this, "setDescription", { needBotAdmin: false, call: () => this.sock.groupUpdateDescription(this.chatId, t || undefined) })
|
|
211
|
+
},
|
|
212
|
+
close() { return simple(this, "close", { call: () => this.sock.groupSettingUpdate(this.chatId, "announcement") }) }, // only admins talk
|
|
213
|
+
open() { return simple(this, "open", { call: () => this.sock.groupSettingUpdate(this.chatId, "not_announcement") }) }, // everyone talks
|
|
214
|
+
lock() { return simple(this, "lock", { call: () => this.sock.groupSettingUpdate(this.chatId, "locked") }) }, // only admins edit group info
|
|
215
|
+
unlock() { return simple(this, "unlock", { call: () => this.sock.groupSettingUpdate(this.chatId, "unlocked") }) },
|
|
216
|
+
|
|
217
|
+
// ----- invite link -----
|
|
218
|
+
link() { return simple(this, "link", { call: () => this.sock.groupInviteCode(this.chatId), map: code => ({ code, url: `https://chat.whatsapp.com/${code}` }) }) },
|
|
219
|
+
revokeLink() { return simple(this, "revokeLink", { call: () => this.sock.groupRevokeInvite(this.chatId), map: code => ({ code, url: `https://chat.whatsapp.com/${code}` }) }) },
|
|
220
|
+
leave() { return simple(this, "leave", { needBotAdmin: false, call: () => this.sock.groupLeave(this.chatId) }) },
|
|
221
|
+
|
|
222
|
+
// ----- delete a message for everyone -----
|
|
223
|
+
// g.delete() -> the replied message (or the command itself if nothing was replied to)
|
|
224
|
+
// g.delete("command") -> the command message
|
|
225
|
+
// g.delete(sentResult) -> a message the bot sent (its { key }) or any Baileys key
|
|
226
|
+
async delete(target) {
|
|
227
|
+
let key
|
|
228
|
+
if (target && typeof target === "object") key = target.key || target
|
|
229
|
+
else if (target === "command" || !this.quoted) key = this.msg?.key
|
|
230
|
+
else {
|
|
231
|
+
const c = this.parsed.context || {}
|
|
232
|
+
const who = c.participant || null
|
|
233
|
+
key = { remoteJid: this.chatId, id: c.stanzaId, fromMe: !!who && botIds(this.sock).includes(normalize(who)), participant: who || undefined }
|
|
234
|
+
}
|
|
235
|
+
if (!key?.id) return fail("delete", "invalid-user")
|
|
236
|
+
const own = !!key.fromMe
|
|
237
|
+
const blocked = await guard(this, "delete", this.isGroup && !own)
|
|
238
|
+
if (blocked && blocked.reason !== "not-group") return blocked
|
|
239
|
+
try { await this.sock.sendMessage(this.chatId, { delete: { ...key, remoteJid: key.remoteJid || this.chatId } }); return success("delete", { key }) }
|
|
240
|
+
catch (e) { return fail("delete", reasonFromError(e), { error: e?.message }) }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { createGroupActions, REASONS, reasonFromError, reasonFromStatus }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @morpho/context — the "g" every command receives: ONE object with everything about the current message.
|
|
2
|
+
//
|
|
3
|
+
// const { createContext } = require("@morpho/context")
|
|
4
|
+
// const contexts = createContext({
|
|
5
|
+
// identity, // createIdentity() from @morpho/jid (required)
|
|
6
|
+
// media, interactive, // createMedia() / createInteractive() (optional: g.download*, buttons)
|
|
7
|
+
// getGroupMeta: (sock, jid) => groups.get(jid), // default sock.groupMetadata(jid) (use @morpho/groups for cache)
|
|
8
|
+
// isOwner: g => g.user.phone === "5511…", // default: only the bot's own device
|
|
9
|
+
// protect: (g, person) => boolean, // extra untouchables for g.kick / g.demote (the bot owner...)
|
|
10
|
+
// prefixFor: (g, jid) => "!", prefix: "/",
|
|
11
|
+
// names: { owner: "Dono", user: "Alguém" },
|
|
12
|
+
// extend: { get vip() { return this.client?.vip }, myHelper() {} } // the bot's own getters/methods
|
|
13
|
+
// })
|
|
14
|
+
// const g = await contexts.build({ sock, msg, clientId, ...anythingElse }) // extra props are copied onto g
|
|
15
|
+
//
|
|
16
|
+
// The shape of g (details: README):
|
|
17
|
+
// message g.text g.type g.id g.isGroup g.isPrivate g.chatId g.quoted g.mentions g.command g.args g.argText g.prefix
|
|
18
|
+
// sender g.user { jid lid phone number name ... } g.isOwner await g.isAdmin() await g.isBotAdmin()
|
|
19
|
+
// people await g.who(x?) -> Person | null Person = { jid, lid, phone, number, admin, superadmin, bot }
|
|
20
|
+
// group await g.group() g.members() g.admins() g.kick g.promote g.demote g.add g.close g.open g.link ...
|
|
21
|
+
// send g.reply g.send g.at g.react g.edit g.delete g.poll
|
|
22
|
+
// media g.media() g.hasMedia await g.download() await g.downloadInfo()
|
|
23
|
+
const { parse } = require("../message")
|
|
24
|
+
const { createGroupActions, REASONS } = require("./group")
|
|
25
|
+
const { createSend } = require("./send")
|
|
26
|
+
const { userPart, participantIds, isAdminRole, botIds } = require("./participants")
|
|
27
|
+
const { normalize, digits } = require("../jid")
|
|
28
|
+
|
|
29
|
+
function createContext({ identity, media, interactive, getGroupMeta, isOwner, protect, prefixFor, prefix = "/", names = {}, interactiveEnabled = () => true, extend = {} } = {}) {
|
|
30
|
+
if (!identity) throw new Error("createContext: 'identity' is required (createIdentity from @morpho/jid)")
|
|
31
|
+
const fetchMeta = getGroupMeta || ((sock, jid) => sock.groupMetadata(jid))
|
|
32
|
+
const nameOf = { owner: "Owner", user: "User", ...names }
|
|
33
|
+
|
|
34
|
+
const proto = {
|
|
35
|
+
msg: null, sock: null, identity: null, clientId: null, user: null,
|
|
36
|
+
|
|
37
|
+
// ----- the message -----
|
|
38
|
+
get parsed() { return parse(this.msg) },
|
|
39
|
+
get id() { return this.msg?.key?.id || null },
|
|
40
|
+
get type() { return this.parsed.type },
|
|
41
|
+
is(...types) { return types.includes(this.parsed.type) },
|
|
42
|
+
get chatId() { return this.msg?.key?.remoteJid || null },
|
|
43
|
+
get isGroup() { return this.parsed.isGroup },
|
|
44
|
+
get isPrivate() { return !this.parsed.isGroup && !this.parsed.isStatus && !this.parsed.isNewsletter },
|
|
45
|
+
get isFromMe() { return this.parsed.fromMe },
|
|
46
|
+
get timestamp() { return this.parsed.timestamp },
|
|
47
|
+
get mentions() { return this.parsed.mentions },
|
|
48
|
+
get quoted() { return this.parsed.quoted }, // parsed quoted message (text, media, sender...) or null
|
|
49
|
+
get readMore() { return "\u200E".repeat(4000) },
|
|
50
|
+
|
|
51
|
+
// text/caption; a tap on one of OUR buttons/list rows counts as the command it carries (e.g. "/menu games")
|
|
52
|
+
get text() { return this.parsed.text || this.interactiveReply || null },
|
|
53
|
+
get interactiveReply() { return interactive ? interactive.parseSelection(this.msg, { sock: this.sock }) : null },
|
|
54
|
+
|
|
55
|
+
// the group's prefix, and the command split: "!ban @x motivo" -> { prefix, name: "ban", args: ["@x","motivo"], argText: "@x motivo" }
|
|
56
|
+
get prefix() { return prefixFor ? prefixFor(this, this.chatId) : prefix },
|
|
57
|
+
get command() {
|
|
58
|
+
const t = this.text, p = this.prefix
|
|
59
|
+
if (!t || !p || !t.startsWith(p)) return null
|
|
60
|
+
const rest = t.slice(p.length).trim()
|
|
61
|
+
if (!rest) return null
|
|
62
|
+
const [name, ...args] = rest.split(/ +/)
|
|
63
|
+
return { prefix: p, name: name.toLowerCase(), args, argText: rest.slice(name.length).trim() }
|
|
64
|
+
},
|
|
65
|
+
get args() { return this.command?.args || [] },
|
|
66
|
+
get argText() { return this.command?.argText || "" },
|
|
67
|
+
|
|
68
|
+
// ----- permissions -----
|
|
69
|
+
get isOwner() { return isOwner ? !!isOwner(this) : this.isFromMe },
|
|
70
|
+
async groupMeta() { return this.isGroup ? fetchMeta(this.sock, this.chatId) : null },
|
|
71
|
+
async isAdmin() {
|
|
72
|
+
if (!this.isGroup) return false
|
|
73
|
+
const meta = await this.groupMeta()
|
|
74
|
+
if (!meta?.participants) return false
|
|
75
|
+
const mine = [this.identity?.jid, this.identity?.lid, this.identity?.pn].filter(Boolean).map(normalize).filter(Boolean)
|
|
76
|
+
if (!mine.length) return false
|
|
77
|
+
return isAdminRole(meta.participants.find(p => participantIds(p).some(x => mine.includes(x))))
|
|
78
|
+
},
|
|
79
|
+
async isBotAdmin() {
|
|
80
|
+
if (!this.isGroup) return false
|
|
81
|
+
const meta = await this.groupMeta()
|
|
82
|
+
if (!meta?.participants) return false
|
|
83
|
+
const me = botIds(this.sock)
|
|
84
|
+
return isAdminRole(meta.participants.find(p => participantIds(p).some(x => me.includes(x))))
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// ----- media (incoming) -----
|
|
88
|
+
// opts: { types: ["image","video","audio"], max (bytes), quoted: "prefer"|"only"|"never", viewOnce: false }
|
|
89
|
+
// The QUOTED media wins over the message's own. View-once only with viewOnce: true (else anyone could reveal it with /s).
|
|
90
|
+
media({ types = ["image", "video", "audio"], quoted, viewOnce = false } = {}) { // look only (type, seconds, size...) -> info | null
|
|
91
|
+
if (!media) throw new Error("g.media: pass 'media' to createContext")
|
|
92
|
+
if (!this.msg?.message) return null
|
|
93
|
+
const found = media.find(this.msg, { types, quoted })
|
|
94
|
+
return !found || (found.viewOnce && !viewOnce) ? null : found
|
|
95
|
+
},
|
|
96
|
+
get hasMedia() { try { return !!this.media() } catch { return false } },
|
|
97
|
+
downloadFound(found, { max } = {}) { return media.downloadFound(found, { sock: this.sock, max }) },
|
|
98
|
+
async downloadInfo(opts) { const f = this.media(opts); return f ? this.downloadFound(f, { max: opts?.max }) : null }, // { buffer, type, mimetype, ... } | null
|
|
99
|
+
async download(opts) { return (await this.downloadInfo(opts))?.buffer ?? null }, // Buffer | null. Errors carry .code (MEDIA_TOO_LARGE, MEDIA_EXPIRED...)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// sending (reply/send/at/react/edit/poll) and group actions (kick/promote/.../who/group/members)
|
|
103
|
+
const mix = o => Object.defineProperties(proto, Object.getOwnPropertyDescriptors(o))
|
|
104
|
+
mix(createSend({ interactive, interactiveEnabled }))
|
|
105
|
+
mix(createGroupActions({ identity, protect }))
|
|
106
|
+
// the bot's own getters/methods (descriptors are copied so getters stay getters)
|
|
107
|
+
mix(extend)
|
|
108
|
+
|
|
109
|
+
// props: { sock, msg, clientId, ...anything }. Resolves the sender ONCE; never throws.
|
|
110
|
+
async function build({ sock, msg, clientId, ...rest }) {
|
|
111
|
+
const g = Object.create(proto)
|
|
112
|
+
Object.assign(g, rest)
|
|
113
|
+
g.msg = msg; g.sock = sock; g.clientId = clientId
|
|
114
|
+
const id = g.identity = await identity.resolveSender(msg, sock, clientId)
|
|
115
|
+
const lid = id?.lid || null, jid = id?.jid || null
|
|
116
|
+
g.user = Object.freeze({
|
|
117
|
+
jid, lid,
|
|
118
|
+
phone: id?.number || null, // the real phone number, or null (LID not resolved yet)
|
|
119
|
+
hasPhone: !!id?.number,
|
|
120
|
+
number: id?.number || (lid ? userPart(lid) : null) || (jid ? userPart(jid) : null), // never empty when there is a sender: the key to use in your database
|
|
121
|
+
formatted: id?.formatted || null,
|
|
122
|
+
source: id?.source || null,
|
|
123
|
+
name: g.isFromMe ? nameOf.owner : (msg?.pushName || nameOf.user)
|
|
124
|
+
})
|
|
125
|
+
media?.bind(msg, sock) // if the link expires, @morpho/media asks the phone to re-send through this sock
|
|
126
|
+
return g
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { proto, build }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { createContext, participantIds, isAdminRole, GROUP_REASONS: REASONS, digits }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/context", "version": "0.2.0", "description": "The per-message context ('g') every command receives: who sent it (LID resolved to a number), text, mentions, quoted message, permissions, target/mention helpers, reply/send helpers and media download. Built on @morpho/message, jid, groups, reply, media and interactive.", "main": "index.js", "dependencies": { "@morpho/message": "file:../message", "@morpho/reply": "file:../reply", "@morpho/jid": "file:../jid" }, "license": "MIT" }
|