morpho-socket 0.3.0 → 0.4.1

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.
@@ -0,0 +1,327 @@
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
+ const userPart = j => String(j || "").split("@")[0].split(":")[0]
193
+ function checkFrame(session, n, ownLid) {
194
+ const found = []
195
+ if (n.tag !== "message") return found
196
+ const a = n.attrs || {}
197
+ const sender = a.participant || a.from
198
+ // the account's own LID (messages sent from the phone / another device) never carries a number: not a problem
199
+ if (ownLid && userPart(sender) === ownLid) return found
200
+ const mode = a.addressing_mode || (isLid(sender) ? "lid" : "pn")
201
+ if (mode === "lid" && !(a.participant_pn || a.sender_pn || a.peer_recipient_pn) && !isSpecial(sender)) {
202
+ found.push(alert(session, "frame_lid_without_number", "warn", { jid: maskJid(sender), addressing_mode: a.addressing_mode ?? null }))
203
+ }
204
+ return found
205
+ }
206
+
207
+ // ---- summaries per event ----
208
+ function summarizeUpsert(data) {
209
+ const list = Array.isArray(data?.messages) ? data.messages : []
210
+ return {
211
+ type: data?.type, count: list.length,
212
+ messages: list.slice(0, MAX_ITEMS).map(m => ({
213
+ key: clean(m?.key),
214
+ messageType: Object.keys(m?.message || {}).find(k => k !== "messageContextInfo" && k !== "senderKeyDistributionMessage") || null,
215
+ stubType: m?.messageStubType ?? undefined,
216
+ hasPushName: !!m?.pushName,
217
+ ts: Number(m?.messageTimestamp) || undefined
218
+ }))
219
+ }
220
+ }
221
+
222
+ function onEvent(session, name, data) {
223
+ try {
224
+ bump(stats.events, name)
225
+ const raw = name === "messages.upsert" ? summarizeUpsert(data) : clean(data)
226
+ const alerts = []
227
+ if (name === "messages.upsert") for (const m of data?.messages || []) alerts.push(...checkKey(session, m?.key))
228
+ if (name === "messages.update") for (const u of Array.isArray(data) ? data : []) alerts.push(...checkKey(session, u?.key))
229
+ alerts.push(...trackShape(session, "event", name, raw))
230
+ write({ t: Date.now(), session, kind: "event", name, data: raw, alerts: alerts.length ? alerts.map(a => a.code) : undefined })
231
+ if (consoleMode === true) console.log(`[capture] ${name} session=${session}`, JSON.stringify(raw).slice(0, 300))
232
+ } catch { stats.errors++ }
233
+ }
234
+
235
+ function onFrame(session, node, sock) {
236
+ try {
237
+ if (isBin(node) || !frameTags.includes(node?.tag)) return
238
+ bump(stats.frames, node.tag)
239
+ const data = cleanNode(node)
240
+ const alerts = checkFrame(session, node, userPart(sock?.user?.lid))
241
+ write({ t: Date.now(), session, kind: "frame", name: node.tag, data, alerts: alerts.length ? alerts.map(a => a.code) : undefined })
242
+ } catch { stats.errors++ }
243
+ }
244
+
245
+ // ---- hooks ----
246
+ function attach(sock, { sessionId = "default" } = {}) {
247
+ const undo = []
248
+ try {
249
+ for (const name of events) {
250
+ const fn = data => onEvent(sessionId, name, data)
251
+ sock.ev.on(name, fn)
252
+ undo.push(() => sock.ev.off?.(name, fn))
253
+ }
254
+ if (frameTags.length && sock.ws?.on) {
255
+ const fn = node => onFrame(sessionId, node, sock)
256
+ sock.ws.on("frame", fn) // every decoded binary node, before Baileys interprets it
257
+ undo.push(() => sock.ws.off?.("frame", fn))
258
+ }
259
+ } catch { stats.errors++ }
260
+ return () => { for (const f of undo) try { f() } catch { /* socket already gone */ } }
261
+ }
262
+ function attachPool(pool) {
263
+ const detaches = new Map()
264
+ pool.on("socket", (id, sock) => { detaches.get(id)?.(); detaches.set(id, attach(sock, { sessionId: id })) })
265
+ return () => { for (const d of detaches.values()) d(); detaches.clear() }
266
+ }
267
+
268
+ // ---- report / fixtures ----
269
+ function report() {
270
+ return JSON.parse(JSON.stringify({ baileys: baileysVersion, ...stats }))
271
+ }
272
+ function reportText() {
273
+ const r = report()
274
+ const l = r.lid
275
+ const lines = [
276
+ `Baileys: ${r.baileys ?? "unknown"}`,
277
+ `Events: ${Object.entries(r.events).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`,
278
+ `Raw nodes: ${Object.entries(r.frames).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`,
279
+ `LID senders: ${l.seen} (with number: ${l.withNumber}, WITHOUT number: ${l.withoutNumber})`,
280
+ `Alerts: ${Object.entries(r.alerts).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`
281
+ ]
282
+ if (r.errors) lines.push(`Capture internal errors (swallowed): ${r.errors}`)
283
+ return lines.join("\n")
284
+ }
285
+
286
+ function readEntries() {
287
+ flush()
288
+ const out = []
289
+ for (const f of [file1, file]) {
290
+ let text = ""
291
+ try { text = fs.readFileSync(f, "utf8") } catch { continue }
292
+ for (const line of text.split("\n")) { if (!line) continue; try { out.push(JSON.parse(line)) } catch { /* cut line at rotation */ } }
293
+ }
294
+ return out
295
+ }
296
+
297
+ // One fixture per distinct (kind, name, shape, alerts), at most `perShape` of each: small files, still covers every variant seen.
298
+ function exportFixtures(outFile, { kinds = ["event", "frame"], perShape = 2 } = {}) {
299
+ const seen = new Map()
300
+ const items = []
301
+ for (const e of readEntries()) {
302
+ if (!kinds.includes(e.kind)) continue
303
+ const sig = [e.kind, e.name, [...paths(e.data)].sort().join(","), (e.alerts || []).join("+")].join("|")
304
+ const n = seen.get(sig) || 0
305
+ if (n >= perShape) continue
306
+ seen.set(sig, n + 1)
307
+ items.push({ kind: e.kind, name: e.name, data: e.data, alerts: e.alerts || [] })
308
+ }
309
+ const doc = { version: 1, baileys: baileysVersion, createdAt: new Date().toISOString(), items }
310
+ fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true })
311
+ fs.writeFileSync(outFile, JSON.stringify(doc, null, 2))
312
+ return { file: outFile, items: items.length }
313
+ }
314
+
315
+ function close() { process.off("exit", onExit); if (timer) clearTimeout(timer); timer = null; flush() }
316
+
317
+ return { attach, attachPool, report, reportText, exportFixtures, flush, close, files: { file, file1 }, mask: maskJid, clean, cleanNode }
318
+ }
319
+
320
+ // reads a fixtures file written by exportFixtures
321
+ function loadFixtures(file) {
322
+ const doc = JSON.parse(fs.readFileSync(file, "utf8"))
323
+ if (!doc || !Array.isArray(doc.items)) throw new Error("loadFixtures: invalid file")
324
+ return doc
325
+ }
326
+
327
+ 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" }
@@ -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" }
@@ -0,0 +1,47 @@
1
+ # morpho-socket/pending
2
+
3
+ Requests that **wait for an answer and expire by themselves**: a challenge, an invite, a trade offer, an "are you sure? yes/no". Morpho doesn't know what the request *means* — it only holds it, lets exactly one caller take it, and forgets it when its time is up.
4
+
5
+ ```js
6
+ const { createPending } = require("morpho-socket/pending")
7
+
8
+ const pending = createPending({ ttlMs: 2 * 60_000 }) // in memory
9
+ // const pending = createPending({ store: db.collection("pending") }) // survives restarts (kv)
10
+
11
+ // the person who is expected to answer is part of the key
12
+ const key = pending.key(g.chatId, target.number)
13
+
14
+ const r = pending.create(key, { from: g.user.number, kind: "duel" })
15
+ if (!r.ok) return g.reply(`There is already an open request (${Math.ceil(r.request.msLeft / 1000)}s left)`)
16
+
17
+ // later, in the "accept" command
18
+ const t = pending.take(key, { match: req => req.data.kind === "duel" })
19
+ if (!t.ok) return g.reply("Nothing to accept.") // t.reason: "not-found" | "mismatch"
20
+ startDuel(t.request.data.from, g.user.number)
21
+ ```
22
+
23
+ ## Behavior
24
+
25
+ - `create` returns `{ ok: false, reason: "already-pending", request }` when one is open, unless you pass `replace: true`. An expired request never blocks a new one.
26
+ - `take` consumes the request once. With `match`, a request that doesn't fit is **left in place** and the answer is `mismatch`.
27
+ - Expiry is checked by Morpho (`request.expiresAt`), so it doesn't depend on the store's clock. Nothing runs in the background; expired entries are dropped when touched.
28
+ - A request is `{ key, data, createdAt, expiresAt, msLeft }`. `data` is anything JSON.
29
+
30
+ ## API
31
+
32
+ | | |
33
+ |---|---|
34
+ | `createPending({ store?, ttlMs = 2 min, now? })` | no `store` → memory |
35
+ | `key(...parts)` | `"part\|part"` |
36
+ | `create(key, data, { ttl?, replace? })` | `{ ok, request }` · `{ ok: false, reason: "already-pending", request }` |
37
+ | `peek(key)` · `has(key)` | look without consuming |
38
+ | `take(key, { match? })` | `{ ok, request }` · `{ ok: false, reason: "not-found" \| "mismatch" }` |
39
+ | `cancel(key)` | `true` if there was one |
40
+ | `list(keyPrefix?)` · `clear(keyPrefix?)` | open requests, oldest first |
41
+ | `sub(...parts)` | isolated view, e.g. `pending.sub(clientId, chatId)` |
42
+
43
+ Wrong usage (empty key, `ttl <= 0`, a `/` inside `sub`) throws `TypeError`; expected outcomes never throw.
44
+
45
+ `store` is duck-typed and must be **synchronous**: `get(key, fallback)`, `set(key, value, { ttl })`, `delete(key)`, `entries(prefix)`. A [`kv`](../kv) collection fits.
46
+
47
+ Need "tell me when it expires"? That is a job for a scheduler, not for this module.