morpho-socket 0.2.0 → 0.4.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 +5 -1
- package/docs/building-a-bot.md +1 -1
- package/package.json +21 -6
- package/packages/ask/README.md +99 -0
- package/packages/ask/index.js +183 -0
- package/packages/ask/package.json +1 -0
- package/packages/capture/README.md +49 -0
- package/packages/capture/index.js +324 -0
- package/packages/capture/package.json +1 -0
- package/packages/context/README.md +3 -0
- package/packages/context/group.js +24 -0
- package/packages/context/index.js +1 -1
- package/packages/links/README.md +85 -0
- package/packages/links/index.js +191 -0
- package/packages/links/package.json +1 -0
- package/packages/pending/README.md +47 -0
- package/packages/pending/index.js +117 -0
- package/packages/pending/package.json +1 -0
- package/packages/scheduler/README.md +79 -0
- package/packages/scheduler/index.js +251 -0
- package/packages/scheduler/package.json +1 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// @morpho/capture
|
|
2
|
+
//
|
|
3
|
+
// Records what Baileys DELIVERS and what Morpho UNDERSTANDS, so a change on the WhatsApp side (like the move to @lid)
|
|
4
|
+
// shows up as an alert instead of as a bot that silently stopped working.
|
|
5
|
+
//
|
|
6
|
+
// Rules this module never breaks:
|
|
7
|
+
// - It never throws into the bot: every listener is wrapped, a capture bug is swallowed (and counted).
|
|
8
|
+
// - It never writes message text, credentials, keys, QR codes or binary content. Strings are replaced by "<str:N>"
|
|
9
|
+
// unless they are a JID (masked) or a short enum-like field (type, action, connection...).
|
|
10
|
+
// - Numbers are masked with a keyed hash (same number -> same fake number, so "who is who" still works in the log).
|
|
11
|
+
//
|
|
12
|
+
// const { createCapture } = require("morpho-socket/capture")
|
|
13
|
+
// const capture = createCapture({ dir: "./capture", baileysVersion: "7.0.0-rc14" })
|
|
14
|
+
// capture.attachPool(pool) // or capture.attach(sock, { sessionId })
|
|
15
|
+
// // ... later
|
|
16
|
+
// console.log(capture.reportText())
|
|
17
|
+
// capture.exportFixtures("./test/fixtures/lid.json")
|
|
18
|
+
|
|
19
|
+
const fs = require("node:fs")
|
|
20
|
+
const path = require("node:path")
|
|
21
|
+
const crypto = require("node:crypto")
|
|
22
|
+
|
|
23
|
+
const EVENTS = [
|
|
24
|
+
"messages.upsert", "messages.update", "lid-mapping.update", "group-participants.update", "groups.upsert",
|
|
25
|
+
"groups.update", "contacts.upsert", "contacts.update", "connection.update", "call"
|
|
26
|
+
]
|
|
27
|
+
const FRAME_TAGS = ["message", "notification", "call"]
|
|
28
|
+
const KEEP_KEYS = new Set(["type", "action", "connection", "tag", "addressingMode", "addressing_mode", "reason", "admin", "fromMe", "status", "stubType", "messageStubType"])
|
|
29
|
+
const KEEP_ATTRS = new Set(["type", "addressing_mode", "category", "offline", "mediatype", "edit", "count", "t", "xmlns", "class"])
|
|
30
|
+
const DROP_KEYS = new Set(["qr", "creds", "keys", "authState", "noiseKey", "signedIdentityKey", "advSecretKey"])
|
|
31
|
+
const JID_RE = /^([\w:.\-]+)@(s\.whatsapp\.net|c\.us|lid|hosted|hosted\.lid|g\.us|broadcast|newsletter|call|bot)$/
|
|
32
|
+
const MAX_DEPTH = 5
|
|
33
|
+
const MAX_ITEMS = 8
|
|
34
|
+
|
|
35
|
+
const isLid = j => typeof j === "string" && /@(hosted\.)?lid$/.test(j)
|
|
36
|
+
const isGroup = j => typeof j === "string" && j.endsWith("@g.us")
|
|
37
|
+
const isSpecial = j => typeof j === "string" && /@(broadcast|newsletter)$/.test(j)
|
|
38
|
+
const isBin = v => Buffer.isBuffer(v) || v instanceof Uint8Array
|
|
39
|
+
|
|
40
|
+
function createCapture(opts = {}) {
|
|
41
|
+
const {
|
|
42
|
+
dir = "./capture", maxBytes = 1_000_000, mask = true,
|
|
43
|
+
events = EVENTS, frameTags = FRAME_TAGS, console: consoleMode = "alerts", flushMs = 300
|
|
44
|
+
} = opts
|
|
45
|
+
let baileysVersion = opts.baileysVersion || null
|
|
46
|
+
if (!baileysVersion) {
|
|
47
|
+
try { baileysVersion = require("../core/baileys").loadFromBot("@whiskeysockets/baileys/package.json").version } catch { /* optional */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
51
|
+
const file = path.join(dir, "capture.ndjson")
|
|
52
|
+
const file1 = path.join(dir, "capture.1.ndjson")
|
|
53
|
+
|
|
54
|
+
// salt: keyed hash, so numbers cannot be recovered by hashing the whole phone-number space
|
|
55
|
+
let salt = opts.salt
|
|
56
|
+
if (!salt) {
|
|
57
|
+
const sf = path.join(dir, ".salt")
|
|
58
|
+
try { salt = fs.readFileSync(sf, "utf8").trim() } catch { /* new dir */ }
|
|
59
|
+
if (!salt) { salt = crypto.randomBytes(16).toString("hex"); try { fs.writeFileSync(sf, salt, { mode: 0o600 }) } catch { /* memory only */ } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- masking ----
|
|
63
|
+
function fake(run) {
|
|
64
|
+
const h = crypto.createHmac("sha256", salt).update(run).digest()
|
|
65
|
+
let out = ""
|
|
66
|
+
for (let i = 0; i < run.length; i++) out += String(h[i % h.length] % 10)
|
|
67
|
+
return out[0] === "0" ? "1" + out.slice(1) : out
|
|
68
|
+
}
|
|
69
|
+
const maskJid = s => {
|
|
70
|
+
if (!mask || typeof s !== "string") return s
|
|
71
|
+
const m = JID_RE.exec(s)
|
|
72
|
+
return m ? m[1].replace(/\d{4,}/g, fake) + "@" + m[2] : s
|
|
73
|
+
}
|
|
74
|
+
const hashId = s => "<id:" + crypto.createHmac("sha256", salt).update(String(s)).digest("hex").slice(0, 8) + ">"
|
|
75
|
+
|
|
76
|
+
// generic sanitizer: keeps SHAPE, drops CONTENT
|
|
77
|
+
function clean(v, key = "", depth = 0) {
|
|
78
|
+
if (v == null || typeof v === "boolean" || typeof v === "number") return v
|
|
79
|
+
if (typeof v === "bigint") return Number(v)
|
|
80
|
+
if (typeof v === "string") {
|
|
81
|
+
if (JID_RE.test(v)) return maskJid(v)
|
|
82
|
+
if (key === "id") return hashId(v)
|
|
83
|
+
if (KEEP_KEYS.has(key)) return v.slice(0, 40)
|
|
84
|
+
return `<str:${v.length}>`
|
|
85
|
+
}
|
|
86
|
+
if (isBin(v)) return `<bin:${v.length}>`
|
|
87
|
+
if (depth >= MAX_DEPTH) return "<deep>"
|
|
88
|
+
if (Array.isArray(v)) {
|
|
89
|
+
const out = v.slice(0, MAX_ITEMS).map(x => clean(x, key, depth + 1))
|
|
90
|
+
if (v.length > MAX_ITEMS) out.push(`<+${v.length - MAX_ITEMS} more>`)
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
if (typeof v === "object") {
|
|
94
|
+
const out = {}
|
|
95
|
+
for (const k of Object.keys(v)) out[k] = DROP_KEYS.has(k) ? "<dropped>" : clean(v[k], k, depth + 1)
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
return `<${typeof v}>`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// a raw binary node ({tag, attrs, content}) without any payload
|
|
102
|
+
function cleanNode(n, depth = 0) {
|
|
103
|
+
if (isBin(n)) return `<bin:${n.length}>`
|
|
104
|
+
if (typeof n === "string") return `<str:${n.length}>`
|
|
105
|
+
if (!n || typeof n !== "object" || depth >= MAX_DEPTH) return "<deep>"
|
|
106
|
+
const attrs = {}
|
|
107
|
+
for (const [k, v] of Object.entries(n.attrs || {})) {
|
|
108
|
+
if (typeof v === "string" && JID_RE.test(v)) attrs[k] = maskJid(v)
|
|
109
|
+
else if (k === "id") attrs[k] = hashId(v)
|
|
110
|
+
else if (KEEP_ATTRS.has(k)) attrs[k] = v
|
|
111
|
+
else attrs[k] = `<str:${String(v).length}>`
|
|
112
|
+
}
|
|
113
|
+
const out = { tag: n.tag, attrs }
|
|
114
|
+
if (Array.isArray(n.content)) {
|
|
115
|
+
out.content = n.content.slice(0, MAX_ITEMS).map(c => cleanNode(c, depth + 1))
|
|
116
|
+
if (n.content.length > MAX_ITEMS) out.content.push(`<+${n.content.length - MAX_ITEMS} more>`)
|
|
117
|
+
} else if (n.content != null) out.content = cleanNode(n.content, depth + 1)
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- state ----
|
|
122
|
+
const stats = { events: {}, frames: {}, alerts: {}, errors: 0, lid: { seen: 0, withNumber: 0, withoutNumber: 0 } }
|
|
123
|
+
const knownPaths = new Map() // "event:name" -> Set(paths)
|
|
124
|
+
let buf = []
|
|
125
|
+
let size = 0
|
|
126
|
+
try { size = fs.statSync(file).size } catch { /* no file yet */ }
|
|
127
|
+
let timer = null
|
|
128
|
+
|
|
129
|
+
const bump = (obj, k) => { obj[k] = (obj[k] || 0) + 1 }
|
|
130
|
+
|
|
131
|
+
function flush() {
|
|
132
|
+
if (!buf.length) return
|
|
133
|
+
const text = buf.join("")
|
|
134
|
+
buf = []
|
|
135
|
+
try {
|
|
136
|
+
if (size + text.length > maxBytes) { try { fs.renameSync(file, file1) } catch { /* first rotation */ } size = 0 }
|
|
137
|
+
fs.appendFileSync(file, text)
|
|
138
|
+
size += text.length
|
|
139
|
+
} catch { stats.errors++ }
|
|
140
|
+
}
|
|
141
|
+
function write(entry) {
|
|
142
|
+
buf.push(JSON.stringify(entry) + "\n")
|
|
143
|
+
if (!timer) { timer = setTimeout(() => { timer = null; flush() }, flushMs); timer.unref?.() }
|
|
144
|
+
}
|
|
145
|
+
const onExit = () => flush()
|
|
146
|
+
process.on("exit", onExit)
|
|
147
|
+
|
|
148
|
+
function alert(session, code, level, detail) {
|
|
149
|
+
bump(stats.alerts, code)
|
|
150
|
+
const a = { code, level, ...detail }
|
|
151
|
+
write({ t: Date.now(), session, kind: "alert", ...a })
|
|
152
|
+
if (consoleMode) console.log(`[capture] ${level === "warn" ? "ALERT" : "note "} ${code} session=${session} ${JSON.stringify(detail)}`)
|
|
153
|
+
return a
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function paths(v, prefix = "", depth = 0, out = new Set()) {
|
|
157
|
+
if (v && typeof v === "object" && !isBin(v) && depth < 4) {
|
|
158
|
+
if (Array.isArray(v)) { if (v.length) paths(v[0], prefix + "[]", depth + 1, out) }
|
|
159
|
+
else for (const k of Object.keys(v)) { out.add(prefix + "." + k); paths(v[k], prefix + "." + k, depth + 1, out) }
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
function trackShape(session, scope, name, summary) {
|
|
164
|
+
const id = scope + ":" + name
|
|
165
|
+
const now = paths(summary)
|
|
166
|
+
const known = knownPaths.get(id)
|
|
167
|
+
if (!known) { knownPaths.set(id, now); return [] } // first sample is the baseline
|
|
168
|
+
const found = []
|
|
169
|
+
for (const p of now) if (!known.has(p)) { known.add(p); found.push(alert(session, "new_field", "info", { scope, name, path: p })) }
|
|
170
|
+
return found
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- LID checks (the fragile point) ----
|
|
174
|
+
function checkKey(session, k) {
|
|
175
|
+
const found = []
|
|
176
|
+
if (!k || k.fromMe) return found
|
|
177
|
+
const group = isGroup(k.remoteJid)
|
|
178
|
+
const who = group ? k.participant : k.remoteJid
|
|
179
|
+
const alt = group ? k.participantAlt : k.remoteJidAlt
|
|
180
|
+
if (!isLid(who) || (!group && isSpecial(who))) return found
|
|
181
|
+
stats.lid.seen++
|
|
182
|
+
if (alt) stats.lid.withNumber++
|
|
183
|
+
else {
|
|
184
|
+
stats.lid.withoutNumber++
|
|
185
|
+
found.push(alert(session, "lid_without_number", "warn", {
|
|
186
|
+
where: group ? "participant" : "remoteJid", jid: maskJid(who), field: group ? "participantAlt" : "remoteJidAlt", present: alt !== undefined
|
|
187
|
+
}))
|
|
188
|
+
}
|
|
189
|
+
return found
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function checkFrame(session, n) {
|
|
193
|
+
const found = []
|
|
194
|
+
if (n.tag !== "message") return found
|
|
195
|
+
const a = n.attrs || {}
|
|
196
|
+
const sender = a.participant || a.from
|
|
197
|
+
const mode = a.addressing_mode || (isLid(sender) ? "lid" : "pn")
|
|
198
|
+
if (mode === "lid" && !(a.participant_pn || a.sender_pn || a.peer_recipient_pn) && !isSpecial(sender)) {
|
|
199
|
+
found.push(alert(session, "frame_lid_without_number", "warn", { jid: maskJid(sender), addressing_mode: a.addressing_mode ?? null }))
|
|
200
|
+
}
|
|
201
|
+
return found
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- summaries per event ----
|
|
205
|
+
function summarizeUpsert(data) {
|
|
206
|
+
const list = Array.isArray(data?.messages) ? data.messages : []
|
|
207
|
+
return {
|
|
208
|
+
type: data?.type, count: list.length,
|
|
209
|
+
messages: list.slice(0, MAX_ITEMS).map(m => ({
|
|
210
|
+
key: clean(m?.key),
|
|
211
|
+
messageType: Object.keys(m?.message || {}).find(k => k !== "messageContextInfo" && k !== "senderKeyDistributionMessage") || null,
|
|
212
|
+
stubType: m?.messageStubType ?? undefined,
|
|
213
|
+
hasPushName: !!m?.pushName,
|
|
214
|
+
ts: Number(m?.messageTimestamp) || undefined
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function onEvent(session, name, data) {
|
|
220
|
+
try {
|
|
221
|
+
bump(stats.events, name)
|
|
222
|
+
const raw = name === "messages.upsert" ? summarizeUpsert(data) : clean(data)
|
|
223
|
+
const alerts = []
|
|
224
|
+
if (name === "messages.upsert") for (const m of data?.messages || []) alerts.push(...checkKey(session, m?.key))
|
|
225
|
+
if (name === "messages.update") for (const u of Array.isArray(data) ? data : []) alerts.push(...checkKey(session, u?.key))
|
|
226
|
+
alerts.push(...trackShape(session, "event", name, raw))
|
|
227
|
+
write({ t: Date.now(), session, kind: "event", name, data: raw, alerts: alerts.length ? alerts.map(a => a.code) : undefined })
|
|
228
|
+
if (consoleMode === true) console.log(`[capture] ${name} session=${session}`, JSON.stringify(raw).slice(0, 300))
|
|
229
|
+
} catch { stats.errors++ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function onFrame(session, node) {
|
|
233
|
+
try {
|
|
234
|
+
if (isBin(node) || !frameTags.includes(node?.tag)) return
|
|
235
|
+
bump(stats.frames, node.tag)
|
|
236
|
+
const data = cleanNode(node)
|
|
237
|
+
const alerts = checkFrame(session, node)
|
|
238
|
+
write({ t: Date.now(), session, kind: "frame", name: node.tag, data, alerts: alerts.length ? alerts.map(a => a.code) : undefined })
|
|
239
|
+
} catch { stats.errors++ }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- hooks ----
|
|
243
|
+
function attach(sock, { sessionId = "default" } = {}) {
|
|
244
|
+
const undo = []
|
|
245
|
+
try {
|
|
246
|
+
for (const name of events) {
|
|
247
|
+
const fn = data => onEvent(sessionId, name, data)
|
|
248
|
+
sock.ev.on(name, fn)
|
|
249
|
+
undo.push(() => sock.ev.off?.(name, fn))
|
|
250
|
+
}
|
|
251
|
+
if (frameTags.length && sock.ws?.on) {
|
|
252
|
+
const fn = node => onFrame(sessionId, node)
|
|
253
|
+
sock.ws.on("frame", fn) // every decoded binary node, before Baileys interprets it
|
|
254
|
+
undo.push(() => sock.ws.off?.("frame", fn))
|
|
255
|
+
}
|
|
256
|
+
} catch { stats.errors++ }
|
|
257
|
+
return () => { for (const f of undo) try { f() } catch { /* socket already gone */ } }
|
|
258
|
+
}
|
|
259
|
+
function attachPool(pool) {
|
|
260
|
+
const detaches = new Map()
|
|
261
|
+
pool.on("socket", (id, sock) => { detaches.get(id)?.(); detaches.set(id, attach(sock, { sessionId: id })) })
|
|
262
|
+
return () => { for (const d of detaches.values()) d(); detaches.clear() }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---- report / fixtures ----
|
|
266
|
+
function report() {
|
|
267
|
+
return JSON.parse(JSON.stringify({ baileys: baileysVersion, ...stats }))
|
|
268
|
+
}
|
|
269
|
+
function reportText() {
|
|
270
|
+
const r = report()
|
|
271
|
+
const l = r.lid
|
|
272
|
+
const lines = [
|
|
273
|
+
`Baileys: ${r.baileys ?? "unknown"}`,
|
|
274
|
+
`Events: ${Object.entries(r.events).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`,
|
|
275
|
+
`Raw nodes: ${Object.entries(r.frames).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`,
|
|
276
|
+
`LID senders: ${l.seen} (with number: ${l.withNumber}, WITHOUT number: ${l.withoutNumber})`,
|
|
277
|
+
`Alerts: ${Object.entries(r.alerts).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`
|
|
278
|
+
]
|
|
279
|
+
if (r.errors) lines.push(`Capture internal errors (swallowed): ${r.errors}`)
|
|
280
|
+
return lines.join("\n")
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readEntries() {
|
|
284
|
+
flush()
|
|
285
|
+
const out = []
|
|
286
|
+
for (const f of [file1, file]) {
|
|
287
|
+
let text = ""
|
|
288
|
+
try { text = fs.readFileSync(f, "utf8") } catch { continue }
|
|
289
|
+
for (const line of text.split("\n")) { if (!line) continue; try { out.push(JSON.parse(line)) } catch { /* cut line at rotation */ } }
|
|
290
|
+
}
|
|
291
|
+
return out
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// One fixture per distinct (kind, name, shape, alerts), at most `perShape` of each: small files, still covers every variant seen.
|
|
295
|
+
function exportFixtures(outFile, { kinds = ["event", "frame"], perShape = 2 } = {}) {
|
|
296
|
+
const seen = new Map()
|
|
297
|
+
const items = []
|
|
298
|
+
for (const e of readEntries()) {
|
|
299
|
+
if (!kinds.includes(e.kind)) continue
|
|
300
|
+
const sig = [e.kind, e.name, [...paths(e.data)].sort().join(","), (e.alerts || []).join("+")].join("|")
|
|
301
|
+
const n = seen.get(sig) || 0
|
|
302
|
+
if (n >= perShape) continue
|
|
303
|
+
seen.set(sig, n + 1)
|
|
304
|
+
items.push({ kind: e.kind, name: e.name, data: e.data, alerts: e.alerts || [] })
|
|
305
|
+
}
|
|
306
|
+
const doc = { version: 1, baileys: baileysVersion, createdAt: new Date().toISOString(), items }
|
|
307
|
+
fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true })
|
|
308
|
+
fs.writeFileSync(outFile, JSON.stringify(doc, null, 2))
|
|
309
|
+
return { file: outFile, items: items.length }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function close() { process.off("exit", onExit); if (timer) clearTimeout(timer); timer = null; flush() }
|
|
313
|
+
|
|
314
|
+
return { attach, attachPool, report, reportText, exportFixtures, flush, close, files: { file, file1 }, mask: maskJid, clean, cleanNode }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// reads a fixtures file written by exportFixtures
|
|
318
|
+
function loadFixtures(file) {
|
|
319
|
+
const doc = JSON.parse(fs.readFileSync(file, "utf8"))
|
|
320
|
+
if (!doc || !Array.isArray(doc.items)) throw new Error("loadFixtures: invalid file")
|
|
321
|
+
return doc
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = { createCapture, loadFixtures, EVENTS, FRAME_TAGS }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/capture", "version": "0.1.0", "description": "Capture tool: records what Baileys delivers (events and raw nodes) with masked data, raises alerts on fragile points (LID without number, new fields) and exports fixtures.", "main": "index.js", "license": "MIT" }
|
|
@@ -58,6 +58,7 @@ if (!r.ok) return g.reply(messages[r.reason] ?? messages.failed)
|
|
|
58
58
|
| | |
|
|
59
59
|
|---|---|
|
|
60
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 }` |
|
|
61
62
|
| group | `close()` `open()` `lock()` `unlock()` `rename(text)` `setDescription(text)` `leave()` |
|
|
62
63
|
| invite link | `link()` · `revokeLink()` → `{ code, url }` |
|
|
63
64
|
| message | `delete()` removes the replied-to message (or the command itself) · `delete("command")` · `delete(sentMessage)` |
|
|
@@ -82,6 +83,8 @@ await g.react("🎉"); await g.poll("Pizza?", ["yes", "no"])
|
|
|
82
83
|
|
|
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`.
|
|
84
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
|
+
|
|
85
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.**
|
|
86
89
|
|
|
87
90
|
## Incoming media
|
|
@@ -161,6 +161,30 @@ function createGroupActions({ identity, protect } = {}) {
|
|
|
161
161
|
async members() { return Promise.all(((await this.groupMeta())?.participants || []).map(p => describe(this, p, false))) },
|
|
162
162
|
async admins() { return (await this.members()).filter(m => m.admin) },
|
|
163
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
|
+
|
|
164
188
|
kick(user, opts = {}) {
|
|
165
189
|
return run(this, "kick", "remove", user, opts, {
|
|
166
190
|
resolve: x => inGroup(this, x),
|
|
@@ -46,7 +46,7 @@ function createContext({ identity, media, interactive, getGroupMeta, isOwner, pr
|
|
|
46
46
|
get timestamp() { return this.parsed.timestamp },
|
|
47
47
|
get mentions() { return this.parsed.mentions },
|
|
48
48
|
get quoted() { return this.parsed.quoted }, // parsed quoted message (text, media, sender...) or null
|
|
49
|
-
get readMore() { return "\u200E".repeat(
|
|
49
|
+
get readMore() { return "\u200E".repeat(4001) }, // `${a}${g.readMore}${b}` -> WhatsApp folds b behind "Read more"
|
|
50
50
|
|
|
51
51
|
// text/caption; a tap on one of OUR buttons/list rows counts as the command it carries (e.g. "/menu games")
|
|
52
52
|
get text() { return this.parsed.text || this.interactiveReply || null },
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# morpho-socket/links
|
|
2
|
+
|
|
3
|
+
A relation between **two people**. What it means is yours — couple, friend, parent, rival, teammate, referral. Morpho stores the edge, enforces the limits you declare and answers "who is linked to whom". Built on [`kv`](../kv).
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const { openKV } = require("morpho-socket/kv")
|
|
7
|
+
const { createLinks, otherOf } = require("morpho-socket/links")
|
|
8
|
+
|
|
9
|
+
const db = openKV("data/bot.sqlite")
|
|
10
|
+
const links = createLinks({
|
|
11
|
+
db,
|
|
12
|
+
types: {
|
|
13
|
+
couple: { symmetric: true, max: 1 }, // one at a time
|
|
14
|
+
friend: { symmetric: true }, // unlimited
|
|
15
|
+
parent: { symmetric: false, max: 2, maxIn: 10 }, // directed: a → b ("a is parent of b")
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const room = links.sub(g.clientId, g.chatId) // isolated per client and group (optional)
|
|
20
|
+
|
|
21
|
+
const r = room.link("couple", g.user.number, target.number, { since: "today" })
|
|
22
|
+
if (!r.ok) return g.reply(ERRORS[r.reason]) // "limit-reached" → r.who is the person at the limit
|
|
23
|
+
|
|
24
|
+
room.get("couple", a, b) // symmetric: order doesn't matter
|
|
25
|
+
room.of(g.user.number) // [{ type, other, direction, data, since, link }]
|
|
26
|
+
room.unlink("couple", a, b)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Symmetric and directed
|
|
30
|
+
|
|
31
|
+
- **Symmetric** (`couple`, `friend`): one edge per pair, any order. `max` is the limit **per person** over all their links of that type.
|
|
32
|
+
- **Directed** (`parent`, `follows`): `a → b` is not `b → a`; each has its own edge. `max` limits a person's **outgoing** links, `maxIn` their **incoming** ones.
|
|
33
|
+
- `max` can be a function of the person — `max: person => plans.limitFor(person)` — so limits can follow your plans.
|
|
34
|
+
|
|
35
|
+
## Outcomes
|
|
36
|
+
|
|
37
|
+
Expected failures are values, never exceptions; you decide what to say and in which language:
|
|
38
|
+
|
|
39
|
+
| `reason` | when |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `self` | a person with themselves |
|
|
42
|
+
| `unknown-type` | the type isn't in `types` (when `types` is given) |
|
|
43
|
+
| `already-linked` | that pair already has a link of this type (in that direction) |
|
|
44
|
+
| `limit-reached` | `who` is the person who is full |
|
|
45
|
+
| `not-linked` | `unlink` / `update` on something that doesn't exist |
|
|
46
|
+
|
|
47
|
+
`link`, `unlink` and `update` run in **one transaction**: a refused call writes nothing.
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `createLinks({ db, name = "links", types?, strict?, now? })` | `types` omitted → any type name works (symmetric, unlimited). With `types`, unknown names are refused unless `strict: false` |
|
|
54
|
+
| `link(type, a, b, data?)` | `{ ok, link }` · `{ ok: false, reason, who? }` |
|
|
55
|
+
| `unlink(type, a, b)` | `{ ok, link }` · `{ ok: false, reason }` — directed needs the exact direction |
|
|
56
|
+
| `update(type, a, b, fn)` | `fn(oldData) → newData`, keeps `since` |
|
|
57
|
+
| `get(type, a, b)` · `has(...)` | the link or `null` |
|
|
58
|
+
| `of(person, { type?, direction? })` | everything a person is part of, oldest first. `direction`: `"sym"` `"out"` `"in"` |
|
|
59
|
+
| `between(a, b)` | every link between two people, any type |
|
|
60
|
+
| `all(type)` | every link of a type in this scope, oldest first (all the couples of a group) |
|
|
61
|
+
| `count(person, type, direction?)` | symmetric → all; directed → outgoing unless you say `"in"` |
|
|
62
|
+
| `unlinkAll(person, { type? })` | when someone leaves or is banned; returns how many were removed |
|
|
63
|
+
| `sub(...parts)` | narrower scope, e.g. `sub(clientId, chatId)` |
|
|
64
|
+
| `otherOf(link, person)` | the other side of a link |
|
|
65
|
+
|
|
66
|
+
A `link` is `{ type, symmetric, a, b, data, since }`. In symmetric links `a`/`b` are in sorted order, so use `otherOf` instead of assuming who is who.
|
|
67
|
+
|
|
68
|
+
People are plain strings — use `g.user.number`. Ids and types can't contain `/`; invalid ones throw `TypeError`.
|
|
69
|
+
|
|
70
|
+
## Example: "marriage" in a few lines
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// !marry @x (with pending for the proposal)
|
|
74
|
+
const key = pending.key(g.chatId, target.number)
|
|
75
|
+
pending.create(key, { from: g.user.number })
|
|
76
|
+
|
|
77
|
+
// !accept
|
|
78
|
+
const t = pending.take(pending.key(g.chatId, g.user.number))
|
|
79
|
+
if (t.ok) {
|
|
80
|
+
const r = room.link("couple", t.request.data.from, g.user.number)
|
|
81
|
+
if (!r.ok) return g.reply(r.reason === "limit-reached" ? "One of you is already taken" : "Couldn't")
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The same two modules build a friendship system, a clan roster or a referral tree — only the type names and limits change.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @morpho/links
|
|
2
|
+
//
|
|
3
|
+
// A relation between TWO people. What it means is yours: couple, friend, parent, rival, teammate, referral...
|
|
4
|
+
// Morpho only stores the edge, enforces the limits you declare, and answers "who is linked to whom".
|
|
5
|
+
//
|
|
6
|
+
// const links = createLinks({
|
|
7
|
+
// db, // from @morpho/kv: openKV(...)
|
|
8
|
+
// types: {
|
|
9
|
+
// couple: { symmetric: true, max: 1 }, // one at a time
|
|
10
|
+
// friend: { symmetric: true }, // as many as you like
|
|
11
|
+
// parent: { symmetric: false, max: 2, maxIn: 10 }, // directed: a -> b (a is parent of b)
|
|
12
|
+
// },
|
|
13
|
+
// })
|
|
14
|
+
// const room = links.sub(clientId, chatId) // isolated: per client and group (optional)
|
|
15
|
+
//
|
|
16
|
+
// room.link("couple", "5511111", "5522222", { nameA: "Ana", nameB: "Bia" }) // { ok: true, link }
|
|
17
|
+
// room.link("couple", "5511111", "5533333") // { ok: false, reason: "limit-reached", who: "5511111" }
|
|
18
|
+
// room.get("couple", "5522222", "5511111") // symmetric: order doesn't matter
|
|
19
|
+
// room.of("5511111") // [{ type, other, direction, data, since, link }]
|
|
20
|
+
// room.unlink("couple", "5511111", "5522222")
|
|
21
|
+
//
|
|
22
|
+
// Expected outcomes are values: { ok: false, reason } with one of
|
|
23
|
+
// "self" | "unknown-type" | "already-linked" | "limit-reached" (+ who) | "not-linked"
|
|
24
|
+
// Wrong usage (an id with "/", an invalid type name) throws TypeError. `link`/`unlink`/`update` run in ONE transaction.
|
|
25
|
+
//
|
|
26
|
+
// People are plain strings (use g.user.number). Directions in `of()`: "sym" (symmetric), "out" (a -> other), "in" (other -> a).
|
|
27
|
+
|
|
28
|
+
const TYPE = /^[A-Za-z0-9_.-]+$/
|
|
29
|
+
const DEFAULTS = { symmetric: true, max: Infinity, maxIn: Infinity }
|
|
30
|
+
|
|
31
|
+
const id = x => {
|
|
32
|
+
if (typeof x === "number" && Number.isFinite(x)) x = String(x)
|
|
33
|
+
if (typeof x !== "string" || !x || x.includes("/")) throw new TypeError('links: a person must be a non-empty string without "/" (use g.user.number)')
|
|
34
|
+
return x
|
|
35
|
+
}
|
|
36
|
+
const typeName = t => { if (typeof t !== "string" || !TYPE.test(t)) throw new TypeError("links: type must match [A-Za-z0-9_.-]+"); return t }
|
|
37
|
+
|
|
38
|
+
// the other side of a link, from `person`'s point of view
|
|
39
|
+
function otherOf(link, person) { return link.a === person ? link.b : link.a }
|
|
40
|
+
|
|
41
|
+
function createLinks({ db, name = "links", types, strict, now = Date.now, _scope = [] } = {}) {
|
|
42
|
+
if (!db || typeof db.collection !== "function" || typeof db.transaction !== "function")
|
|
43
|
+
throw new TypeError("links: pass { db } from @morpho/kv (openKV(...))")
|
|
44
|
+
const col = _scope.length ? db.collection(name).sub(..._scope) : db.collection(name)
|
|
45
|
+
const known = types ? Object.fromEntries(Object.entries(types).map(([k, v]) => [typeName(k), { ...DEFAULTS, ...v }])) : null
|
|
46
|
+
const isStrict = strict ?? !!known
|
|
47
|
+
|
|
48
|
+
const cfg = t => known?.[t] ?? (isStrict ? null : DEFAULTS)
|
|
49
|
+
const limit = (v, person) => (typeof v === "function" ? v(person) : v)
|
|
50
|
+
|
|
51
|
+
// keys
|
|
52
|
+
const edgeKey = (t, a, b) => `e/${t}/${a}/${b}`
|
|
53
|
+
const ixKey = (person, t, dir, other) => `i/${person}/${t}/${dir}/${other}` // dir: s (symmetric) | o (out) | i (in)
|
|
54
|
+
const canon = (t, c, a, b) => (c.symmetric && a > b ? [b, a] : [a, b])
|
|
55
|
+
const DIR = { s: "sym", o: "out", i: "in" }
|
|
56
|
+
|
|
57
|
+
const toLink = (t, c, rec) => ({ type: t, symmetric: c.symmetric, a: rec.a, b: rec.b, data: rec.data, since: rec.since })
|
|
58
|
+
|
|
59
|
+
function readEdge(t, c, a, b) {
|
|
60
|
+
const [x, y] = canon(t, c, a, b)
|
|
61
|
+
const rec = col.get(edgeKey(t, x, y))
|
|
62
|
+
return rec ? toLink(t, c, rec) : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const api = {
|
|
66
|
+
// data: any JSON. Returns { ok: true, link } | { ok: false, reason, who? }
|
|
67
|
+
link(type, a, b, data = null) {
|
|
68
|
+
typeName(type); a = id(a); b = id(b)
|
|
69
|
+
const c = cfg(type)
|
|
70
|
+
if (!c) return { ok: false, reason: "unknown-type" }
|
|
71
|
+
if (a === b) return { ok: false, reason: "self" }
|
|
72
|
+
return db.transaction(() => {
|
|
73
|
+
if (readEdge(type, c, a, b)) return { ok: false, reason: "already-linked" }
|
|
74
|
+
// limits, per person: symmetric -> all their links of that type; directed -> outgoing (max) and incoming (maxIn)
|
|
75
|
+
const checks = c.symmetric
|
|
76
|
+
? [[a, "s", c.max], [b, "s", c.max]]
|
|
77
|
+
: [[a, "o", c.max], [b, "i", c.maxIn]]
|
|
78
|
+
for (const [person, dir, lim] of checks) {
|
|
79
|
+
const n = col.count(`i/${person}/${type}/${dir}/`)
|
|
80
|
+
if (n >= limit(lim, person)) return { ok: false, reason: "limit-reached", who: person }
|
|
81
|
+
}
|
|
82
|
+
const [x, y] = canon(type, c, a, b)
|
|
83
|
+
const rec = { a: x, b: y, data, since: now() }
|
|
84
|
+
col.set(edgeKey(type, x, y), rec)
|
|
85
|
+
if (c.symmetric) { col.set(ixKey(x, type, "s", y), true); col.set(ixKey(y, type, "s", x), true) }
|
|
86
|
+
else { col.set(ixKey(x, type, "o", y), true); col.set(ixKey(y, type, "i", x), true) }
|
|
87
|
+
return { ok: true, link: toLink(type, c, rec) }
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// symmetric: any order. directed: exactly a -> b
|
|
92
|
+
unlink(type, a, b) {
|
|
93
|
+
typeName(type); a = id(a); b = id(b)
|
|
94
|
+
const c = cfg(type)
|
|
95
|
+
if (!c) return { ok: false, reason: "unknown-type" }
|
|
96
|
+
return db.transaction(() => {
|
|
97
|
+
const link = readEdge(type, c, a, b)
|
|
98
|
+
if (!link) return { ok: false, reason: "not-linked" }
|
|
99
|
+
col.delete(edgeKey(type, link.a, link.b))
|
|
100
|
+
if (c.symmetric) { col.delete(ixKey(link.a, type, "s", link.b)); col.delete(ixKey(link.b, type, "s", link.a)) }
|
|
101
|
+
else { col.delete(ixKey(link.a, type, "o", link.b)); col.delete(ixKey(link.b, type, "i", link.a)) }
|
|
102
|
+
return { ok: true, link }
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
get(type, a, b) {
|
|
107
|
+
typeName(type); a = id(a); b = id(b)
|
|
108
|
+
const c = cfg(type)
|
|
109
|
+
return c ? readEdge(type, c, a, b) : null
|
|
110
|
+
},
|
|
111
|
+
has(type, a, b) { return !!api.get(type, a, b) },
|
|
112
|
+
|
|
113
|
+
// change the data of an existing link: fn(oldData) -> newData
|
|
114
|
+
update(type, a, b, fn) {
|
|
115
|
+
typeName(type); a = id(a); b = id(b)
|
|
116
|
+
const c = cfg(type)
|
|
117
|
+
if (!c) return { ok: false, reason: "unknown-type" }
|
|
118
|
+
return db.transaction(() => {
|
|
119
|
+
const link = readEdge(type, c, a, b)
|
|
120
|
+
if (!link) return { ok: false, reason: "not-linked" }
|
|
121
|
+
const next = fn(link.data)
|
|
122
|
+
if (next === undefined) throw new TypeError("links.update: return the new data (or null)")
|
|
123
|
+
const rec = { a: link.a, b: link.b, data: next, since: link.since }
|
|
124
|
+
col.set(edgeKey(type, link.a, link.b), rec)
|
|
125
|
+
return { ok: true, link: toLink(type, c, rec) }
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// everything `person` is part of. opts: { type, direction: "sym" | "out" | "in" } (default: all). Oldest first.
|
|
130
|
+
of(person, { type, direction } = {}) {
|
|
131
|
+
person = id(person)
|
|
132
|
+
if (type !== undefined) typeName(type)
|
|
133
|
+
const out = []
|
|
134
|
+
for (const k of col.keys(`i/${person}/${type ? type + "/" : ""}`)) {
|
|
135
|
+
const [, , t, d, ...rest] = k.split("/")
|
|
136
|
+
const other = rest.join("/")
|
|
137
|
+
const c = cfg(t) ?? DEFAULTS
|
|
138
|
+
if (direction && DIR[d] !== direction) continue
|
|
139
|
+
// the index doesn't know the edge's orientation; the edge key does: symmetric is canonical, directed follows `d`
|
|
140
|
+
const link = d === "i" ? readEdge(t, { ...c, symmetric: false }, other, person) : readEdge(t, { ...c, symmetric: d === "s" }, person, other)
|
|
141
|
+
if (link) out.push({ type: t, other, direction: DIR[d], data: link.data, since: link.since, link })
|
|
142
|
+
}
|
|
143
|
+
return out.sort((x, y) => x.since - y.since)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// how many links `person` has of that type (direction as in of(); default: symmetric -> all, directed -> out)
|
|
147
|
+
count(person, type, direction) {
|
|
148
|
+
person = id(person); typeName(type)
|
|
149
|
+
const c = cfg(type) ?? DEFAULTS
|
|
150
|
+
const d = direction ? { sym: "s", out: "o", in: "i" }[direction] : (c.symmetric ? "s" : "o")
|
|
151
|
+
if (!d) throw new TypeError('links.count: direction must be "sym", "out" or "in"')
|
|
152
|
+
return col.count(`i/${person}/${type}/${d}/`)
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// every link between two people, any type, any direction
|
|
156
|
+
between(a, b) {
|
|
157
|
+
a = id(a); b = id(b)
|
|
158
|
+
return api.of(a).filter(x => x.other === b).map(x => x.link)
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// every link of a type in this scope, oldest first (e.g. all the couples of a group)
|
|
162
|
+
all(type) {
|
|
163
|
+
typeName(type)
|
|
164
|
+
const c = cfg(type)
|
|
165
|
+
if (!c) return []
|
|
166
|
+
return col.values(`e/${type}/`).map(rec => toLink(type, c, rec)).sort((x, y) => x.since - y.since)
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// remove every link of a person (they left, were banned...). opts: { type }. Returns how many were removed.
|
|
170
|
+
unlinkAll(person, { type } = {}) {
|
|
171
|
+
person = id(person)
|
|
172
|
+
return db.transaction(() => {
|
|
173
|
+
let n = 0
|
|
174
|
+
for (const x of api.of(person, { type })) {
|
|
175
|
+
const r = x.direction === "in" ? api.unlink(x.type, x.other, person) : api.unlink(x.type, person, x.other)
|
|
176
|
+
if (r.ok) n++
|
|
177
|
+
}
|
|
178
|
+
return n
|
|
179
|
+
})
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// narrower scope (links.sub(clientId, chatId)): one client or group never sees another's links
|
|
183
|
+
sub(...parts) {
|
|
184
|
+
if (!parts.length) throw new TypeError("links.sub: pass at least one part")
|
|
185
|
+
return createLinks({ db, name, types, strict, now, _scope: [..._scope, ...parts] })
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
return api
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { createLinks, otherOf }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "name": "@morpho/links", "version": "0.1.0", "description": "Relations between two people (couple, friend, parent, rival, team...): one edge per pair, optional per-person limits, queries both ways. Built on @morpho/kv.", "main": "index.js", "engines": { "node": ">=22.5.0" }, "license": "MIT" }
|