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.
Files changed (59) hide show
  1. package/README.md +155 -2
  2. package/docs/building-a-bot.md +402 -0
  3. package/package.json +35 -4
  4. package/packages/commands/README.md +78 -0
  5. package/packages/commands/antiflood.js +39 -0
  6. package/packages/commands/index.js +116 -0
  7. package/packages/commands/package.json +1 -0
  8. package/packages/context/README.md +100 -0
  9. package/packages/context/group.js +269 -0
  10. package/packages/context/index.js +132 -0
  11. package/packages/context/package.json +1 -0
  12. package/packages/context/participants.js +12 -0
  13. package/packages/context/send.js +127 -0
  14. package/packages/core/README.md +50 -0
  15. package/packages/core/baileys.js +25 -0
  16. package/packages/core/codec.js +17 -0
  17. package/packages/core/index.js +86 -0
  18. package/packages/core/package.json +7 -0
  19. package/packages/core/store.js +11 -0
  20. package/packages/groups/README.md +42 -0
  21. package/packages/groups/index.js +205 -0
  22. package/packages/groups/package.json +7 -0
  23. package/packages/interactive/README.md +41 -0
  24. package/packages/interactive/index.js +92 -0
  25. package/packages/interactive/package.json +1 -0
  26. package/packages/jid/README.md +51 -0
  27. package/packages/jid/index.js +285 -0
  28. package/packages/jid/package.json +7 -0
  29. package/packages/kv/README.md +45 -0
  30. package/packages/kv/index.js +164 -0
  31. package/packages/kv/package.json +8 -0
  32. package/packages/media/README.md +70 -0
  33. package/packages/media/cache.js +116 -0
  34. package/packages/media/erros.js +45 -0
  35. package/packages/media/find.js +51 -0
  36. package/packages/media/index.js +261 -0
  37. package/packages/media/package.json +8 -0
  38. package/packages/message/README.md +53 -0
  39. package/packages/message/index.js +317 -0
  40. package/packages/message/package.json +7 -0
  41. package/packages/pool/README.md +75 -0
  42. package/packages/pool/index.js +369 -0
  43. package/packages/pool/package.json +8 -0
  44. package/packages/presence/README.md +40 -0
  45. package/packages/presence/index.js +80 -0
  46. package/packages/presence/package.json +1 -0
  47. package/packages/reply/README.md +36 -0
  48. package/packages/reply/index.js +126 -0
  49. package/packages/reply/package.json +7 -0
  50. package/packages/reply/voice.js +32 -0
  51. package/packages/sent/README.md +27 -0
  52. package/packages/sent/index.js +65 -0
  53. package/packages/sent/package.json +1 -0
  54. package/packages/store-file/README.md +28 -0
  55. package/packages/store-file/index.js +63 -0
  56. package/packages/store-file/package.json +8 -0
  57. package/packages/store-sqlite/README.md +25 -0
  58. package/packages/store-sqlite/index.js +98 -0
  59. package/packages/store-sqlite/package.json +9 -0
@@ -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 }