diff --git a/.ai/context.md b/.ai/context.md index c187763..fdd5ac4 100644 --- a/.ai/context.md +++ b/.ai/context.md @@ -150,9 +150,20 @@ fallback for manual reconciliation. (GitHub tokens, Discord webhooks). It never stores LLM auth — that is the CLI's concern. - IPC: Unix socket at `~/.vesper/run/vesper.sock` (interface defined in Foundation; server can be a stub returning 501 for non-`ping` methods). -- **Only dependency is `@biomejs/biome` (devDep)** (+ `@types/bun` for Bun TS types). No - `commander`/`zod`/`keytar` — hand-roll the small CLI arg parser. Bun-only: `bun install`, - `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Core stays dependency-free.** `vesper-core`, `vesper-cli`, and `vesper-ui` ship only + `@biomejs/biome` (devDep) (+ `@types/bun`). No `commander`/`zod`/`keytar` — hand-roll the small + CLI arg parser. Bun-only: `bun install`, `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Opt-in package exception (the ONLY runtime dependency; authorized by Omar 2026-06-05).** The + isolated `@vesper/channel-whatsapp-web` package bundles **Baileys** (`baileys`) — a + reverse-engineered WhatsApp-Web client — to enable personal-account QR pairing. Deliberate and + contained: Baileys and its transitive deps (incl. a native Rust bridge in v7) live ONLY in that + package; core + ui never reference it, and the cli only DECLARES it (an `optionalDependency`) and + lazy-loads it at boot via a variable-specifier dynamic `import()` (`loadOptionalChannels`) — so it + never enters the static module graph or the compiled binary, and a distributed build can omit it + (`--omit=optional`). This does **NOT** relax Hard rule 12 (no LLM + provider SDKs) — Baileys is a messaging-channel transport, not an LLM SDK — and is not a precedent: + any further runtime dependency requires the same explicit Omar authorization + isolation in its own + opt-in package. - UI: **Bun/TypeScript/web stack** for any user-facing surface (local-first, ESM-only, served from the existing TS toolchain). **No Rust/Tauri by default** — Tauri is opt-in only when a capability strictly requires a native shell, and only after surfacing the need to Omar (see Hard rule 14). @@ -327,9 +338,20 @@ DEV-93/94/36/90/98/99/100 Cancelled as superseded by the bring-your-own-CLI + el (per-issue reasons recorded; reversible). DEV-13 + DEV-48 cancellation was blocked by the permission classifier — pending Omar's call. +**Connections (messaging channels) SHIPPED.** Channels are a plugin (`connections/` core + a +`ChannelRegistry` the daemon/CLI/UI iterate): Telegram (long-poll), Discord (Gateway), WhatsApp +Cloud-API send-only, each a handler + one catalog/registry entry. **Scan-to-connect (PR #9)** adds +QR pairing: a dependency-free portable QR encoder (`media/qr`) + an optional `Pairable` capability; +Telegram's `t.me/?start=` deep-link QR auto-captures the chat id, Discord uses an OAuth2 +invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single inbound long-poll; +`POST /api/connections/:id/pair` streams pairing updates as ndjson to both the Vesper World "Connect" +canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in +`@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out +under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **514 tests / 0 fail**; Biome clean; no -provider SDKs. +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); Voice (`specs/voice-modalities.md`, needs the Hard-rule-12 contract amendment first); the one-line diff --git a/.ai/generated/rules.mdc b/.ai/generated/rules.mdc index 47d0d7a..3e1c884 100644 --- a/.ai/generated/rules.mdc +++ b/.ai/generated/rules.mdc @@ -158,9 +158,20 @@ fallback for manual reconciliation. (GitHub tokens, Discord webhooks). It never stores LLM auth — that is the CLI's concern. - IPC: Unix socket at `~/.vesper/run/vesper.sock` (interface defined in Foundation; server can be a stub returning 501 for non-`ping` methods). -- **Only dependency is `@biomejs/biome` (devDep)** (+ `@types/bun` for Bun TS types). No - `commander`/`zod`/`keytar` — hand-roll the small CLI arg parser. Bun-only: `bun install`, - `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Core stays dependency-free.** `vesper-core`, `vesper-cli`, and `vesper-ui` ship only + `@biomejs/biome` (devDep) (+ `@types/bun`). No `commander`/`zod`/`keytar` — hand-roll the small + CLI arg parser. Bun-only: `bun install`, `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Opt-in package exception (the ONLY runtime dependency; authorized by Omar 2026-06-05).** The + isolated `@vesper/channel-whatsapp-web` package bundles **Baileys** (`baileys`) — a + reverse-engineered WhatsApp-Web client — to enable personal-account QR pairing. Deliberate and + contained: Baileys and its transitive deps (incl. a native Rust bridge in v7) live ONLY in that + package; core + ui never reference it, and the cli only DECLARES it (an `optionalDependency`) and + lazy-loads it at boot via a variable-specifier dynamic `import()` (`loadOptionalChannels`) — so it + never enters the static module graph or the compiled binary, and a distributed build can omit it + (`--omit=optional`). This does **NOT** relax Hard rule 12 (no LLM + provider SDKs) — Baileys is a messaging-channel transport, not an LLM SDK — and is not a precedent: + any further runtime dependency requires the same explicit Omar authorization + isolation in its own + opt-in package. - UI: **Bun/TypeScript/web stack** for any user-facing surface (local-first, ESM-only, served from the existing TS toolchain). **No Rust/Tauri by default** — Tauri is opt-in only when a capability strictly requires a native shell, and only after surfacing the need to Omar (see Hard rule 14). @@ -335,9 +346,20 @@ DEV-93/94/36/90/98/99/100 Cancelled as superseded by the bring-your-own-CLI + el (per-issue reasons recorded; reversible). DEV-13 + DEV-48 cancellation was blocked by the permission classifier — pending Omar's call. +**Connections (messaging channels) SHIPPED.** Channels are a plugin (`connections/` core + a +`ChannelRegistry` the daemon/CLI/UI iterate): Telegram (long-poll), Discord (Gateway), WhatsApp +Cloud-API send-only, each a handler + one catalog/registry entry. **Scan-to-connect (PR #9)** adds +QR pairing: a dependency-free portable QR encoder (`media/qr`) + an optional `Pairable` capability; +Telegram's `t.me/?start=` deep-link QR auto-captures the chat id, Discord uses an OAuth2 +invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single inbound long-poll; +`POST /api/connections/:id/pair` streams pairing updates as ndjson to both the Vesper World "Connect" +canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in +`@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out +under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **514 tests / 0 fail**; Biome clean; no -provider SDKs. +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); Voice (`specs/voice-modalities.md`, needs the Hard-rule-12 contract amendment first); the one-line diff --git a/AGENTS.md b/AGENTS.md index efd174d..0fc6592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,9 +152,20 @@ fallback for manual reconciliation. (GitHub tokens, Discord webhooks). It never stores LLM auth — that is the CLI's concern. - IPC: Unix socket at `~/.vesper/run/vesper.sock` (interface defined in Foundation; server can be a stub returning 501 for non-`ping` methods). -- **Only dependency is `@biomejs/biome` (devDep)** (+ `@types/bun` for Bun TS types). No - `commander`/`zod`/`keytar` — hand-roll the small CLI arg parser. Bun-only: `bun install`, - `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Core stays dependency-free.** `vesper-core`, `vesper-cli`, and `vesper-ui` ship only + `@biomejs/biome` (devDep) (+ `@types/bun`). No `commander`/`zod`/`keytar` — hand-roll the small + CLI arg parser. Bun-only: `bun install`, `bun run`, `bun test`, `bun x`. No npm/yarn. +- **Opt-in package exception (the ONLY runtime dependency; authorized by Omar 2026-06-05).** The + isolated `@vesper/channel-whatsapp-web` package bundles **Baileys** (`baileys`) — a + reverse-engineered WhatsApp-Web client — to enable personal-account QR pairing. Deliberate and + contained: Baileys and its transitive deps (incl. a native Rust bridge in v7) live ONLY in that + package; core + ui never reference it, and the cli only DECLARES it (an `optionalDependency`) and + lazy-loads it at boot via a variable-specifier dynamic `import()` (`loadOptionalChannels`) — so it + never enters the static module graph or the compiled binary, and a distributed build can omit it + (`--omit=optional`). This does **NOT** relax Hard rule 12 (no LLM + provider SDKs) — Baileys is a messaging-channel transport, not an LLM SDK — and is not a precedent: + any further runtime dependency requires the same explicit Omar authorization + isolation in its own + opt-in package. - UI: **Bun/TypeScript/web stack** for any user-facing surface (local-first, ESM-only, served from the existing TS toolchain). **No Rust/Tauri by default** — Tauri is opt-in only when a capability strictly requires a native shell, and only after surfacing the need to Omar (see Hard rule 14). @@ -329,9 +340,20 @@ DEV-93/94/36/90/98/99/100 Cancelled as superseded by the bring-your-own-CLI + el (per-issue reasons recorded; reversible). DEV-13 + DEV-48 cancellation was blocked by the permission classifier — pending Omar's call. +**Connections (messaging channels) SHIPPED.** Channels are a plugin (`connections/` core + a +`ChannelRegistry` the daemon/CLI/UI iterate): Telegram (long-poll), Discord (Gateway), WhatsApp +Cloud-API send-only, each a handler + one catalog/registry entry. **Scan-to-connect (PR #9)** adds +QR pairing: a dependency-free portable QR encoder (`media/qr`) + an optional `Pairable` capability; +Telegram's `t.me/?start=` deep-link QR auto-captures the chat id, Discord uses an OAuth2 +invite QR + `pair `. A daemon `PairingCoordinator` multiplexes the single inbound long-poll; +`POST /api/connections/:id/pair` streams pairing updates as ndjson to both the Vesper World "Connect" +canvas-QR card and `vesper connections pair`. **WhatsApp-Web (personal account)** lands as the opt-in +`@vesper/channel-whatsapp-web` package — the SOLE runtime dependency (Baileys; see the opt-in carve-out +under Stack), lazy-registered by the daemon, with rotating-QR pairing into a vault-backed session. + **Agent docs** — single-source `.ai/` drives Claude Code, opencode, Codex, Gemini, and Cursor via -`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **514 tests / 0 fail**; Biome clean; no -provider SDKs. +`bun run sync:ai` (`scripts/sync-ai-docs.ts`). Suite: **870 tests / 0 fail**; Biome clean; no +provider SDKs (the lone runtime dep is the isolated, opt-in Baileys in `@vesper/channel-whatsapp-web`). **Next:** the Vesper World UI redesign (Omar dislikes the current look — a design prompt is in hand); Voice (`specs/voice-modalities.md`, needs the Hard-rule-12 contract amendment first); the one-line diff --git a/bun.lock b/bun.lock index e7a1efb..8415c3c 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,14 @@ "@vesper/ui": "workspace:*", }, }, + "packages/channel-whatsapp-web": { + "name": "@vesper/channel-whatsapp-web", + "version": "0.1.0", + "dependencies": { + "@vesper/core": "workspace:*", + "baileys": "7.0.0-rc13", + }, + }, "packages/pipelines": { "name": "@vesper/pipelines", "version": "0.1.0", @@ -28,6 +36,9 @@ "@vesper/pipelines": "workspace:*", "@vesper/ui": "workspace:*", }, + "optionalDependencies": { + "@vesper/channel-whatsapp-web": "workspace:*", + }, }, "packages/vesper-core": { "name": "@vesper/core", @@ -67,6 +78,96 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@cacheable/memory": ["@cacheable/memory@2.0.9", "", { "dependencies": { "@cacheable/utils": "^2.4.1", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA=="], + + "@cacheable/node-cache": ["@cacheable/node-cache@1.7.6", "", { "dependencies": { "cacheable": "^2.3.1", "hookified": "^1.14.0", "keyv": "^5.5.5" } }, "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A=="], + + "@cacheable/utils": ["@cacheable/utils@2.4.1", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@hapi/boom": ["@hapi/boom@9.1.4", "", { "dependencies": { "@hapi/hoek": "9.x.x" } }, "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw=="], + + "@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], @@ -91,10 +192,16 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@vesper/channel-whatsapp-web": ["@vesper/channel-whatsapp-web@workspace:packages/channel-whatsapp-web"], + "@vesper/cli": ["@vesper/cli@workspace:packages/vesper-cli"], "@vesper/core": ["@vesper/core@workspace:packages/vesper-core"], @@ -105,8 +212,98 @@ "@vesper/ui": ["@vesper/ui@workspace:packages/vesper-ui"], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "baileys": ["baileys@7.0.0-rc13", "", { "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", "libsignal": "^6.0.0", "lru-cache": "^11.1.0", "music-metadata": "^11.12.3", "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.5.6", "whatsapp-rust-bridge": "0.5.4", "ws": "^8.13.0" }, "peerDependencies": { "audio-decode": "^2.1.3", "jimp": "^1.6.1", "link-preview-js": "^3.0.0", "sharp": "*" }, "optionalPeers": ["audio-decode", "jimp", "link-preview-js"] }, "sha512-v8k74K8B5R7WNYGa26MyJAYEu3Wc4BSuK01QaK8lr30lhE8Nga31nWNu8KN0NDDt+Fsvkq4SQFFI8Q13ghjKmA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cacheable": ["cacheable@2.3.5", "", { "dependencies": { "@cacheable/memory": "^2.0.8", "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "curve25519-js": ["curve25519-js@0.0.4", "", {}, "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "file-type": ["file-type@21.3.4", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g=="], + + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + + "libsignal": ["libsignal@6.0.0", "", { "dependencies": { "curve25519-js": "^0.0.4", "protobufjs": "^7.5.5" } }, "sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "music-metadata": ["music-metadata@11.12.3", "", { "dependencies": { "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "whatsapp-rust-bridge": ["whatsapp-rust-bridge@0.5.4", "", {}, "sha512-yYO1qSs0Fe7tGtnxOFHomocUD6IZtoAgmA4oDFyGIRZ67D3QZk3w7swA6XXFXNQngiyrg2k7tul6IrM3eUFh7A=="], + + "win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], } } diff --git a/cycle-log.md b/cycle-log.md index 352d695..9eb0d72 100644 --- a/cycle-log.md +++ b/cycle-log.md @@ -927,3 +927,45 @@ Backend->Client->Review workflow; the review's 2 real HIGH gaps were then fixed package (Baileys isolated + lazy-imported so core stays dep-free) + a rotating-QR pairing session + the `.ai/context.md` amendment carving out that one dependency. Also: Signal (signal-cli, real QR device-link); optionally browser token entry behind the approval gate; filtering the pairing `/start` message from the chat. + +## Scan-to-connect — WhatsApp-Web (personal account) via Baileys, opt-in — SHIPPED +- The deferred slice G of scan-to-connect. Omar shipped the dep-free v1 (PR #9) first, then greenlit the heavy + WhatsApp-Web path. Issue-capped: this entry + the commit are the record (Rule 11). +- DEPENDENCY DISCOVERY (surfaced to Omar before building): the latest Baileys (`baileys@7.0.0-rc13`, dist-tag + `latest`) is an RC AND pulls a native Rust bridge (`whatsapp-rust-bridge`) + libsignal/protobuf/pino (~11 + transitive). The stable `legacy` 6.7.23 avoids Rust but is older-protocol + git-URL deps. Omar chose v7-rc + (current protocol; accepts the RC + Rust). Install + runtime-load verified (no blocked native scripts; the + Rust bridge ships prebuilt). Brushes Hard rule 14 (no Rust by default) — recorded in the contract, but 14 is + a UI-stack rule and this is a transitive dep inside one opt-in package, so not a violation. +- ISOLATION ARCHITECTURE (the crux): `plugin.build()` is SYNC, so a lazy `import()` can't live inside it. Solution + — make the core plugin registry EXTENSIBLE (`registerChannelPlugin`/`unregisterChannelPlugin`; `channelPluginById` + checks built-ins then a runtime map) so core ships zero Baileys, and the daemon registers the optional plugin at + boot. `whatsapp-web` is a real catalog id + ChannelId (transport `qr-web`); `available`/`pairable` derive from + registration (honest gate). The cli declares `@vesper/channel-whatsapp-web` as an `optionalDependency` (so it + RESOLVES in the workspace — an undeclared sibling does not) and `loadOptionalChannels()` loads it via a VARIABLE- + specifier dynamic `import()` (kept out of tsc's resolution + the compiled binary's static graph; `--omit=optional` + drops it from a distributed build). Lesson: in a Bun monorepo a dynamic `import()` of a sibling only resolves if + the importer DECLARES it — the first integration test caught this (registered === []), fixed by the optional dep. +- PAIRING SHAPE DIVERGENCE: WhatsApp-Web pairing is SELF-DRIVING (drives its own Baileys socket; the scan ESTABLISHES + auth) — unlike Telegram/Discord which watch the daemon's inbound stream for a nonce and need a prior authenticate. + Added `pairingNeedsInbound?: boolean` to the plugin (default true; whatsapp-web false). The coordinator now branches: + self-driving channels skip the authenticate precondition + the transient receiver + `subscribeInbound`, and pairing + `linked` carries NO chatId — so `#persistLinked` enables the channel even without one. Telegram/Discord behavior + is byte-identical (default true). +- THE PACKAGE (`@vesper/channel-whatsapp-web`): `WhatsAppWebHandler` (ChannelHandler + Pairable) with an injected + `WASocketFactory` seam (tests inject a fake — no live WhatsApp, no real socket). `makeVaultAuthState` ports Baileys' + `useMultiFileAuthState` to a SINGLE vault blob (`whatsapp_web_session`), serialized with `BufferJSON` (incl. the + `app-state-sync-key` proto re-wrap), rewritten on every key `set` + `creds.update`. `startPairing` bridges + `connection.update` to an async queue so rotating QRs preserve repeated `awaiting` (kind `code`); `open` -> save + + `linked`; `close` -> `error`. `receive` maps non-fromMe text to InboundMessage (two-way works once paired); `send` + uses the live receive socket (throws when not connected). +- CONTRACT AMENDMENT: `.ai/context.md` Stack section now carves out the ONE opt-in runtime dependency (cites Omar + 2026-05 [2026-06-05] auth, the isolation mechanism, that it does NOT relax Hard rule 12 and is not a precedent); + `bun run sync:ai` regenerated AGENTS.md + rules.mdc. "Where we are" updated with the Connections/scan-to-connect arc. +- Verified: 870 tests / 0 fail (+16: 15 package + 1 lazy-registration integration); biome clean; tsc adds 0 new errors + in the new package + my wiring; the `whatsapp-web` catalog addition broke ZERO existing tests (partial-match assertions). + NOT exercised against a live WhatsApp account (no scan in CI; the socket seam is mocked end-to-end). +- KNOWN LIMITATIONS / follow-ups: `vesper connections list` in the CLI process shows whatsapp-web `available:false` + (the plugin is registered only in the daemon — the UI/daemon is the source of truth; the CLI doesn't load Baileys + for a list); the compiled `vesper-desktop` binary omits whatsapp-web (dynamic import not bundled) until Launch wires + it; re-pairing an already-live whatsapp-web opens a second socket (rare edge). Signal (signal-cli) still open. diff --git a/packages/channel-whatsapp-web/package.json b/packages/channel-whatsapp-web/package.json new file mode 100644 index 0000000..d35a7bb --- /dev/null +++ b/packages/channel-whatsapp-web/package.json @@ -0,0 +1,14 @@ +{ + "name": "@vesper/channel-whatsapp-web", + "version": "0.1.0", + "private": true, + "type": "module", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@vesper/core": "workspace:*", + "baileys": "7.0.0-rc13" + } +} diff --git a/packages/channel-whatsapp-web/src/handler.test.ts b/packages/channel-whatsapp-web/src/handler.test.ts new file mode 100644 index 0000000..d3cca1a --- /dev/null +++ b/packages/channel-whatsapp-web/src/handler.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from "bun:test"; +import { + type Capability, + type InboundMessage, + type PairingUpdate, + type Vault, + VaultError, +} from "@vesper/core"; +import { + type WASocket, + type WASocketConfig, + type WASocketFactory, + WhatsAppWebHandler, +} from "./handler.ts"; + +const GRANTED: readonly Capability[] = ["READ_VAULT", "WRITE_VAULT"]; +const KEY = "whatsapp_web_session"; + +/** An in-memory {@link Vault} that throws `VaultError(not_found)` for an absent key. */ +function memoryVault(seed?: Record): Vault & { store: Map } { + const store = new Map(Object.entries(seed ?? {})); + return { + store, + get: async (key) => { + const value = store.get(key); + if (value === undefined) throw new VaultError("not_found", `no ${key}`); + return value; + }, + set: async (key, value) => { + store.set(key, value); + }, + delete: async (key) => { + if (!store.delete(key)) throw new VaultError("not_found", `no ${key}`); + }, + list: async () => [...store.keys()].sort(), + }; +} + +/** A controllable Baileys socket: captures listeners + sent messages, lets a test emit events. */ +function fakeSocket() { + const listeners = new Map void>>(); + const sent: Array<{ jid: string; text: string }> = []; + let ended = false; + const socket: WASocket = { + ev: { + on: (event, listener) => { + const bucket = listeners.get(event) ?? []; + bucket.push(listener); + listeners.set(event, bucket); + }, + }, + sendMessage: async (jid, content) => { + sent.push({ jid, text: content.text }); + return {}; + }, + end: () => { + ended = true; + }, + }; + return { + socket, + sent, + isEnded: () => ended, + emit: (event: string, payload: unknown) => { + for (const cb of listeners.get(event) ?? []) cb(payload); + }, + }; +} + +/** A factory that records each built socket; the caller supplies the next fake. */ +function recordingFactory(...sockets: ReturnType[]): { + factory: WASocketFactory; + configs: WASocketConfig[]; +} { + const configs: WASocketConfig[] = []; + let index = 0; + const factory: WASocketFactory = (config) => { + configs.push(config); + const next = sockets[index++]; + if (next === undefined) throw new Error("recordingFactory: no more fake sockets"); + return next.socket; + }; + return { factory, configs }; +} + +/** Drain an async-iterable of updates into an array. */ +async function collect(updates: AsyncIterable): Promise { + const out: PairingUpdate[] = []; + for await (const update of updates) out.push(update); + return out; +} + +/** Spin the microtask/macrotask queue until `predicate` holds (the async `start()` settled). */ +async function waitFor(predicate: () => boolean): Promise { + for (let i = 0; i < 100 && !predicate(); i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("WhatsAppWebHandler — descriptor", () => { + test("uses the whatsapp-web catalog descriptor", () => { + const h = new WhatsAppWebHandler({ granted: GRANTED }); + expect(h.descriptor.id).toBe("whatsapp-web"); + expect(h.descriptor.transport).toBe("qr-web"); + }); +}); + +describe("WhatsAppWebHandler — startPairing", () => { + test("yields one awaiting 'code' update per QR rotation, then linked + saves the vault", async () => { + const sock = fakeSocket(); + const { factory, configs } = recordingFactory(sock); + const vault = memoryVault(); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + + const session = h.startPairing({ vault }); + const updatesPromise = collect(session.updates()); + + // Wait until `start()` built the socket + registered listeners. + await waitFor(() => configs.length > 0); + expect(configs[0]?.printQRInTerminal).toBe(false); + + sock.emit("connection.update", { qr: "QR1" }); + sock.emit("connection.update", { qr: "QR2" }); + sock.emit("creds.update", {}); + sock.emit("connection.update", { connection: "open" }); + + const updates = await updatesPromise; + const awaiting = updates.filter((u) => u.status === "awaiting"); + expect(awaiting).toHaveLength(2); + for (const u of awaiting) { + if (u.status === "awaiting") expect(u.prompt.kind).toBe("code"); + } + expect(awaiting[0]?.status === "awaiting" && awaiting[0].prompt.data).toBe("QR1"); + expect(awaiting[1]?.status === "awaiting" && awaiting[1].prompt.data).toBe("QR2"); + + const last = updates.at(-1); + expect(last?.status).toBe("linked"); + if (last?.status === "linked") expect(last.chatId).toBeUndefined(); + // saveCreds ran on open -> vault blob written. + expect(vault.store.has(KEY)).toBe(true); + expect(sock.isEnded()).toBe(true); + }); + + test("yields an error update on a non-recoverable connection close", async () => { + const sock = fakeSocket(); + const { factory, configs } = recordingFactory(sock); + const vault = memoryVault(); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + + const session = h.startPairing({ vault }); + const updatesPromise = collect(session.updates()); + await waitFor(() => configs.length > 0); + + sock.emit("connection.update", { + connection: "close", + lastDisconnect: { error: new Error("boom") }, + }); + + const updates = await updatesPromise; + const last = updates.at(-1); + expect(last?.status).toBe("error"); + if (last?.status === "error") expect(last.reason).toContain("boom"); + }); + + test("stop() yields an expired update and ends the socket", async () => { + const sock = fakeSocket(); + const { factory, configs } = recordingFactory(sock); + const vault = memoryVault(); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + + const session = h.startPairing({ vault }); + const updatesPromise = collect(session.updates()); + await waitFor(() => configs.length > 0); + + session.stop(); + session.stop(); // idempotent + + const updates = await updatesPromise; + expect(updates.at(-1)?.status).toBe("expired"); + expect(sock.isEnded()).toBe(true); + }); +}); + +describe("WhatsAppWebHandler — authenticate", () => { + test("throws not_authenticated when no session blob is stored", async () => { + const h = new WhatsAppWebHandler({ granted: GRANTED }); + await expect(h.authenticate(memoryVault())).rejects.toMatchObject({ + reason: "not_authenticated", + }); + }); + + test("loads the stored session blob", async () => { + // Seed a real blob by driving a pairing to `open` (which calls saveCreds). + const sock = fakeSocket(); + const { factory, configs } = recordingFactory(sock); + const vault = memoryVault(); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + const session = h.startPairing({ vault }); + const updatesPromise = collect(session.updates()); + await waitFor(() => configs.length > 0); + sock.emit("connection.update", { connection: "open" }); + await updatesPromise; + expect(vault.store.has(KEY)).toBe(true); + + // A fresh handler authenticates from that persisted blob. + const h2 = new WhatsAppWebHandler({ granted: GRANTED }); + await h2.authenticate(vault); + }); +}); + +describe("WhatsAppWebHandler — receive + send", () => { + test("receive feeds a non-fromMe text message into the sink; fromMe is ignored", async () => { + const recvSock = fakeSocket(); + const { factory } = recordingFactory(recvSock); + const vault = memoryVault({ [KEY]: '{"creds":{},"keys":{}}' }); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + await h.authenticate(vault); + + const got: InboundMessage[] = []; + const handle = h.receive(async (m) => { + got.push(m); + }); + + recvSock.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { remoteJid: "123@s.whatsapp.net", fromMe: true }, + message: { conversation: "mine" }, + }, + { + key: { + remoteJid: "123@s.whatsapp.net", + fromMe: false, + participant: "456@s.whatsapp.net", + }, + message: { conversation: "hello vesper" }, + }, + ], + }); + + await Promise.resolve(); + expect(got).toHaveLength(1); + expect(got[0]).toMatchObject({ + channel: "whatsapp-web", + chatId: "123@s.whatsapp.net", + from: "456@s.whatsapp.net", + text: "hello vesper", + }); + handle.stop(); + expect(recvSock.isEnded()).toBe(true); + }); + + test("receive reads extendedTextMessage text too", async () => { + const recvSock = fakeSocket(); + const { factory } = recordingFactory(recvSock); + const vault = memoryVault({ [KEY]: '{"creds":{},"keys":{}}' }); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + await h.authenticate(vault); + + const got: InboundMessage[] = []; + h.receive(async (m) => { + got.push(m); + }); + recvSock.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { remoteJid: "9@s.whatsapp.net", fromMe: false }, + message: { extendedTextMessage: { text: "rich text" } }, + }, + ], + }); + await Promise.resolve(); + expect(got[0]?.text).toBe("rich text"); + expect(got[0]?.from).toBe("9@s.whatsapp.net"); + }); + + test("send throws when not connected", async () => { + const h = new WhatsAppWebHandler({ granted: GRANTED }); + await expect( + h.send({ kind: "reply", chatId: "123@s.whatsapp.net", text: "hi" }), + ).rejects.toMatchObject({ reason: "send_failed" }); + }); + + test("send delivers on the socket receive opened", async () => { + const recvSock = fakeSocket(); + const { factory } = recordingFactory(recvSock); + const vault = memoryVault({ [KEY]: '{"creds":{},"keys":{}}' }); + const h = new WhatsAppWebHandler({ granted: GRANTED, socketFactory: factory }); + await h.authenticate(vault); + h.receive(async () => {}); + + await h.send({ kind: "reply", chatId: "123@s.whatsapp.net", text: "pong" }); + expect(recvSock.sent).toEqual([{ jid: "123@s.whatsapp.net", text: "pong" }]); + }); +}); diff --git a/packages/channel-whatsapp-web/src/handler.ts b/packages/channel-whatsapp-web/src/handler.ts new file mode 100644 index 0000000..5030270 --- /dev/null +++ b/packages/channel-whatsapp-web/src/handler.ts @@ -0,0 +1,380 @@ +/** + * The WhatsApp-Web (personal-account) channel handler. Unlike the bot-API channels, + * this links a real WhatsApp account by QR scan over the WhatsApp-Web protocol + * (Baileys). It is the opt-in `@vesper/channel-whatsapp-web` package's whole reason + * to exist — the Baileys dependency lives ONLY here, never in core. + * + * It is pure transport (Hard rule 12): pairing drives a Baileys socket to obtain a + * linked session (persisted to the vault via {@link makeVaultAuthState}); `receive` + * re-opens that session and feeds inbound text into the {@link ChatSink}; `send` + * posts a text message on the live socket. Egress is the WhatsApp-Web WebSocket that + * Baileys owns — there is no `allowlistedFetch` seam for this transport (see the + * catalog descriptor note). + * + * The Baileys socket is reached ONLY through the small {@link WASocketFactory} seam, + * so the whole test suite injects a fake and never opens a real WebSocket. + */ + +import { + type Capability, + type ChannelDescriptor, + type ChannelHandler, + type ChatSink, + ConnectionError, + channelById, + type InboundMessage, + type OutboundIntent, + type Pairable, + type PairingDeps, + type PairingSession, + type PairingUpdate, + type Stoppable, + type Vault, +} from "@vesper/core"; +import makeWASocket, { type AuthenticationState, DisconnectReason } from "baileys"; +import { makeVaultAuthState } from "./vault-auth-state.ts"; + +/** How long a rendered QR is advertised as valid before WhatsApp rotates it. */ +const QR_ROTATE_MS = 60_000; + +/** The WhatsApp-Web catalog descriptor (non-null — it is a built-in catalog id). */ +const WHATSAPP_WEB_DESCRIPTOR = channelById("whatsapp-web"); + +/** + * The minimal slice of a Baileys socket this handler depends on. Modeling only what + * we use keeps the injection seam tiny and lets the suite supply a fake without + * reconstructing the full `WASocket` type. + */ +export interface WASocket { + readonly ev: { + on(event: string, listener: (payload: unknown) => void): void; + off?(event: string, listener: (payload: unknown) => void): void; + }; + sendMessage(jid: string, content: { text: string }): Promise; + /** Close the underlying WebSocket. Baileys exposes `end(error?)`. */ + end(error?: Error): void; +} + +/** The config we hand the factory — a structural subset of Baileys' `UserFacingSocketConfig`. */ +export interface WASocketConfig { + readonly auth: AuthenticationState; + readonly printQRInTerminal: boolean; +} + +/** Builds a Baileys socket from a config. Injected so tests never open a real socket. */ +export type WASocketFactory = (config: WASocketConfig) => WASocket; + +/** Options for {@link WhatsAppWebHandler}. */ +export interface WhatsAppWebHandlerOptions { + /** Capabilities the handler was granted. */ + readonly granted: readonly Capability[]; + /** Vault KEY the linked session blob is stored under. Defaults to `whatsapp_web_session`. */ + readonly vaultKey?: string; + /** Builds a Baileys socket; defaults to the real `makeWASocket`. */ + readonly socketFactory?: WASocketFactory; +} + +/** A Baileys `connection.update` payload (the subset we narrow against). */ +interface ConnectionUpdate { + readonly connection?: "connecting" | "open" | "close"; + readonly lastDisconnect?: { readonly error?: unknown }; + readonly qr?: string; +} + +/** A Baileys `messages.upsert` payload (the subset we narrow against). */ +interface MessagesUpsert { + readonly messages: readonly WAMessageLike[]; + readonly type: string; +} + +/** The slice of a Baileys `WAMessage` this handler reads. */ +interface WAMessageLike { + readonly key?: { + readonly remoteJid?: string | null; + readonly fromMe?: boolean | null; + readonly participant?: string | null; + }; + readonly message?: { + readonly conversation?: string | null; + readonly extendedTextMessage?: { readonly text?: string | null } | null; + } | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** The real socket factory: thin wrapper over `makeWASocket`. */ +const defaultSocketFactory: WASocketFactory = (config) => + makeWASocket(config) as unknown as WASocket; + +export class WhatsAppWebHandler implements ChannelHandler, Pairable { + readonly descriptor: ChannelDescriptor; + /** Capabilities this handler was granted (recorded for audit; this transport has no fetch seam). */ + readonly granted: readonly Capability[]; + readonly #vaultKey: string; + readonly #socketFactory: WASocketFactory; + #authState: AuthenticationState | null = null; + #liveSocket: WASocket | null = null; + + constructor(options: WhatsAppWebHandlerOptions) { + if (WHATSAPP_WEB_DESCRIPTOR === undefined) { + throw new Error("whatsapp-web descriptor missing from catalog"); + } + this.descriptor = WHATSAPP_WEB_DESCRIPTOR; + this.granted = options.granted; + this.#vaultKey = options.vaultKey ?? "whatsapp_web_session"; + this.#socketFactory = options.socketFactory ?? defaultSocketFactory; + } + + /** Load the linked session from the vault; an unpaired account is skipped by the daemon. */ + async authenticate(vault: Vault): Promise { + let blob: string; + try { + blob = await vault.get(this.#vaultKey); + } catch { + throw new ConnectionError( + "not_authenticated", + "whatsapp-web has no linked session — pair it first", + ); + } + if (blob.trim() === "") { + throw new ConnectionError("not_authenticated", "whatsapp-web session blob is empty"); + } + const { state } = await makeVaultAuthState(vault, this.#vaultKey); + this.#authState = state; + } + + /** + * Self-driving QR pairing (ignores {@link PairingDeps.subscribeInbound}). Opens a + * Baileys socket from a fresh vault-backed auth state and bridges its + * `connection.update`/`creds.update` events into an async queue the generator + * drains in order — `awaiting` REPEATS as WhatsApp rotates the QR, so a single + * promise would drop rotations. + */ + startPairing(deps: PairingDeps): PairingSession { + const queue = new UpdateQueue(); + let socket: WASocket | null = null; + let stopped = false; + let saveCreds: (() => Promise) | null = null; + + const closeSocket = (): void => { + if (socket === null) return; + try { + socket.end(); + } catch { + // end() is best-effort; a double-close must not throw. + } + socket = null; + }; + + const onConnection = (payload: unknown): void => { + const update = asConnectionUpdate(payload); + if (update.qr !== undefined) { + queue.push({ + status: "awaiting", + prompt: { + kind: "code", + data: update.qr, + humanHint: + "Open WhatsApp > Settings > Linked Devices > Link a Device, and scan this code.", + expiresAt: Date.now() + QR_ROTATE_MS, + }, + }); + return; + } + if (update.connection === "open") { + void (async () => { + if (saveCreds !== null) await saveCreds(); + queue.push({ status: "linked" }); + queue.close(); + closeSocket(); + })(); + return; + } + if (update.connection === "close") { + queue.push({ status: "error", reason: describeDisconnect(update.lastDisconnect?.error) }); + queue.close(); + closeSocket(); + } + }; + + const start = async (): Promise => { + const authState = await makeVaultAuthState(deps.vault, this.#vaultKey); + saveCreds = authState.saveCreds; + if (stopped) return; + socket = this.#socketFactory({ auth: authState.state, printQRInTerminal: false }); + socket.ev.on("connection.update", onConnection); + socket.ev.on("creds.update", () => { + void authState.saveCreds(); + }); + }; + + void start().catch((error: unknown) => { + queue.push({ status: "error", reason: describeError(error) }); + queue.close(); + }); + + return { + updates: () => queue.drain(), + stop: () => { + if (stopped) return; + stopped = true; + if (queue.pushIfOpen({ status: "expired" })) queue.close(); + closeSocket(); + }, + }; + } + + /** Send a text message on the live socket opened by {@link receive}. */ + async send(intent: OutboundIntent): Promise { + if (this.#liveSocket === null) { + throw new ConnectionError("send_failed", "whatsapp-web is not connected"); + } + await this.#liveSocket.sendMessage(intent.chatId, { text: intent.text }); + } + + /** + * Open a socket from the loaded auth state and feed each non-`fromMe` text message + * into `sink`. The socket reference is kept so {@link send} can reply on it. + */ + receive(sink: ChatSink): Stoppable { + if (this.#authState === null) { + throw new ConnectionError( + "not_authenticated", + "whatsapp-web must authenticate before receive", + ); + } + const socket = this.#socketFactory({ auth: this.#authState, printQRInTerminal: false }); + this.#liveSocket = socket; + + socket.ev.on("messages.upsert", (payload: unknown) => { + const upsert = asMessagesUpsert(payload); + if (upsert === null) return; + for (const raw of upsert.messages) { + const inbound = toInbound(raw); + if (inbound === null) continue; + void Promise.resolve(sink(inbound)).catch(() => { + // A sink failure (chatbot down) must not stop ingress. + }); + } + }); + + return { + stop: () => { + if (this.#liveSocket === socket) this.#liveSocket = null; + try { + socket.end(); + } catch { + // Idempotent close. + } + }, + }; + } +} + +/** + * A tiny async queue: producers `push` {@link PairingUpdate}s, a single consumer + * `drain()`s them as an async generator. It exists because `awaiting` fires + * repeatedly (QR rotation) — a queue preserves order and never drops an update + * arriving before the consumer awaits it. + */ +class UpdateQueue { + #buffer: PairingUpdate[] = []; + #closed = false; + #wake: (() => void) | null = null; + + push(update: PairingUpdate): void { + if (this.#closed) return; + this.#buffer.push(update); + this.#wake?.(); + this.#wake = null; + } + + /** Push only while open; returns whether the push landed (used by an idempotent stop). */ + pushIfOpen(update: PairingUpdate): boolean { + if (this.#closed) return false; + this.push(update); + return true; + } + + close(): void { + this.#closed = true; + this.#wake?.(); + this.#wake = null; + } + + async *drain(): AsyncGenerator { + while (true) { + while (this.#buffer.length > 0) { + yield this.#buffer.shift() as PairingUpdate; + } + if (this.#closed) return; + await new Promise((resolve) => { + this.#wake = resolve; + }); + } + } +} + +/** Narrow an unknown `connection.update` payload to the fields we read. */ +function asConnectionUpdate(payload: unknown): ConnectionUpdate { + if (!isRecord(payload)) return {}; + const connection = + payload.connection === "connecting" || + payload.connection === "open" || + payload.connection === "close" + ? payload.connection + : undefined; + const lastDisconnect = isRecord(payload.lastDisconnect) + ? { error: payload.lastDisconnect.error } + : undefined; + const qr = typeof payload.qr === "string" ? payload.qr : undefined; + return { + ...(connection !== undefined ? { connection } : {}), + ...(lastDisconnect !== undefined ? { lastDisconnect } : {}), + ...(qr !== undefined ? { qr } : {}), + }; +} + +/** Narrow an unknown `messages.upsert` payload, or null if it is not one. */ +function asMessagesUpsert(payload: unknown): MessagesUpsert | null { + if (!isRecord(payload) || !Array.isArray(payload.messages)) return null; + return { + messages: payload.messages as readonly WAMessageLike[], + type: typeof payload.type === "string" ? payload.type : "", + }; +} + +/** Build an {@link InboundMessage} from a Baileys message, or null to skip it. */ +function toInbound(raw: WAMessageLike): InboundMessage | null { + const key = raw.key; + if (key === undefined || key.fromMe === true) return null; + const remoteJid = key.remoteJid ?? undefined; + if (remoteJid === undefined || remoteJid === null) return null; + const text = raw.message?.conversation ?? raw.message?.extendedTextMessage?.text ?? undefined; + if (text === undefined || text === null || text === "") return null; + return { + channel: "whatsapp-web", + chatId: remoteJid, + from: key.participant ?? remoteJid, + text, + ts: Date.now(), + }; +} + +/** Human-readable reason for a `connection: "close"` disconnect. */ +function describeDisconnect(error: unknown): string { + if (isRecord(error)) { + const status = isRecord(error.output) ? error.output.statusCode : undefined; + if (status === DisconnectReason.loggedOut) return "whatsapp-web logged out"; + } + return describeError(error); +} + +/** A safe string for any thrown value. */ +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "whatsapp-web connection closed"; +} diff --git a/packages/channel-whatsapp-web/src/index.ts b/packages/channel-whatsapp-web/src/index.ts new file mode 100644 index 0000000..bff37f0 --- /dev/null +++ b/packages/channel-whatsapp-web/src/index.ts @@ -0,0 +1,30 @@ +/** + * `@vesper/channel-whatsapp-web` — the OPT-IN home of the Baileys dependency that + * adds WhatsApp-Web (personal-account) QR pairing. Core ships ZERO of this; the + * daemon/CLI lazily imports this package at boot and calls + * `registerChannelPlugin(whatsappWebPlugin)`. + * + * The plugin is SELF-DRIVING (`pairingNeedsInbound: false`): the handler drives its + * own Baileys socket and establishes auth via the scan itself, so the coordinator + * skips the `authenticate` precondition and the transient receive loop. + */ + +import type { ChannelPlugin } from "@vesper/core"; +import { WhatsAppWebHandler } from "./handler.ts"; + +export { + type WASocket, + type WASocketConfig, + type WASocketFactory, + WhatsAppWebHandler, + type WhatsAppWebHandlerOptions, +} from "./handler.ts"; +export { makeVaultAuthState, type VaultAuthState } from "./vault-auth-state.ts"; + +/** The opt-in WhatsApp-Web channel plugin. Register it at boot to make the channel available. */ +export const whatsappWebPlugin: ChannelPlugin = { + id: "whatsapp-web", + pairable: true, + pairingNeedsInbound: false, + build: (opts) => new WhatsAppWebHandler({ granted: opts.granted, vaultKey: opts.vaultKey }), +}; diff --git a/packages/channel-whatsapp-web/src/vault-auth-state.test.ts b/packages/channel-whatsapp-web/src/vault-auth-state.test.ts new file mode 100644 index 0000000..6a5f7f2 --- /dev/null +++ b/packages/channel-whatsapp-web/src/vault-auth-state.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { type Vault, VaultError } from "@vesper/core"; +import { makeVaultAuthState } from "./vault-auth-state.ts"; + +/** An in-memory {@link Vault} that throws `VaultError(not_found)` for an absent key. */ +function memoryVault(seed?: Record): Vault & { store: Map } { + const store = new Map(Object.entries(seed ?? {})); + return { + store, + get: async (key) => { + const value = store.get(key); + if (value === undefined) throw new VaultError("not_found", `no ${key}`); + return value; + }, + set: async (key, value) => { + store.set(key, value); + }, + delete: async (key) => { + if (!store.delete(key)) throw new VaultError("not_found", `no ${key}`); + }, + list: async () => [...store.keys()].sort(), + }; +} + +const KEY = "whatsapp_web_session"; + +describe("makeVaultAuthState", () => { + test("seeds fresh creds when the vault has no entry", async () => { + const vault = memoryVault(); + const { state } = await makeVaultAuthState(vault, KEY); + expect(state.creds.registered).toBe(false); + // initAuthCreds gives a noiseKey with Buffer/Uint8Array material. + expect(state.creds.noiseKey.private.length).toBeGreaterThan(0); + // Nothing persisted until a set/saveCreds happens. + expect(vault.store.has(KEY)).toBe(false); + }); + + test("saveCreds persists the blob and a reload yields the same registrationId", async () => { + const vault = memoryVault(); + const first = await makeVaultAuthState(vault, KEY); + await first.saveCreds(); + expect(vault.store.has(KEY)).toBe(true); + + const reloaded = await makeVaultAuthState(vault, KEY); + expect(reloaded.state.creds.registrationId).toBe(first.state.creds.registrationId); + }); + + test("key store round-trips a set value through a reload (Buffers survive BufferJSON)", async () => { + const vault = memoryVault(); + const { state } = await makeVaultAuthState(vault, KEY); + const keyId = "5"; + const material = { + public: new Uint8Array([1, 2, 3, 4]), + private: new Uint8Array([5, 6, 7, 8]), + }; + await state.keys.set({ "pre-key": { [keyId]: material } }); + expect(vault.store.has(KEY)).toBe(true); + + const reloaded = await makeVaultAuthState(vault, KEY); + const got = await reloaded.state.keys.get("pre-key", [keyId]); + expect(got[keyId]).toBeDefined(); + expect([...(got[keyId] as { public: Uint8Array }).public]).toEqual([1, 2, 3, 4]); + expect([...(got[keyId] as { private: Uint8Array }).private]).toEqual([5, 6, 7, 8]); + }); + + test("set with a null value deletes the key", async () => { + const vault = memoryVault(); + const { state } = await makeVaultAuthState(vault, KEY); + await state.keys.set({ session: { a: new Uint8Array([9]) } }); + await state.keys.set({ session: { a: null } }); + const got = await state.keys.get("session", ["a"]); + expect(got.a).toBeUndefined(); + }); + + test("app-state-sync-key values are re-wrapped as proto on read", async () => { + const vault = memoryVault(); + const { state } = await makeVaultAuthState(vault, KEY); + await state.keys.set({ + "app-state-sync-key": { k1: { keyData: new Uint8Array([1]), fingerprint: {}, timestamp: 0 } }, + }); + const reloaded = await makeVaultAuthState(vault, KEY); + const got = await reloaded.state.keys.get("app-state-sync-key", ["k1"]); + // proto.Message.AppStateSyncKeyData.fromObject produces an object with a toJSON method. + expect(got.k1).toBeDefined(); + expect(typeof (got.k1 as { toJSON?: unknown }).toJSON).toBe("function"); + }); +}); diff --git a/packages/channel-whatsapp-web/src/vault-auth-state.ts b/packages/channel-whatsapp-web/src/vault-auth-state.ts new file mode 100644 index 0000000..aa873fc --- /dev/null +++ b/packages/channel-whatsapp-web/src/vault-auth-state.ts @@ -0,0 +1,122 @@ +/** + * A vault-backed Baileys auth-state — a single-blob PORT of Baileys' + * `useMultiFileAuthState`. Where the reference scatters `creds.json` plus one file + * per signal key across a folder, this keeps the WHOLE state (`{ creds, keys }`) in a + * single Vesper {@link Vault} entry, serialized with `BufferJSON` (so `Buffer`/ + * `Uint8Array` key material round-trips). The blob is rewritten on every key `set` + * and on `saveCreds`, which is more than enough for a single personal account. + * + * Faithful to the reference in two load-bearing details: the in-memory key store is + * read/written through the same `{ get(type, ids), set(data) }` SignalKeyStore shape, + * and `app-state-sync-key` values are re-wrapped through + * `proto.Message.AppStateSyncKeyData.fromObject` on read (Baileys stores them as a + * proto message, not a plain object). + */ + +import { type Vault, VaultError } from "@vesper/core"; +import { + type AuthenticationCreds, + type AuthenticationState, + BufferJSON, + initAuthCreds, + proto, + type SignalDataSet, + type SignalDataTypeMap, +} from "baileys"; + +/** The persisted shape: creds plus the nested `category -> id -> value` key map. */ +interface AuthBlob { + creds: AuthenticationCreds; + keys: KeyMap; +} + +/** In-memory key store: `category -> id -> serialized value` (values are BufferJSON-revived). */ +type KeyMap = Record>; + +/** The auth state plus its persist hook, matching Baileys' `useMultiFileAuthState` return. */ +export interface VaultAuthState { + readonly state: AuthenticationState; + readonly saveCreds: () => Promise; +} + +/** Is this the vault's typed "key not present yet" rejection? Anything else re-throws. */ +function isNotFound(error: unknown): boolean { + return error instanceof VaultError && error.reason === "not_found"; +} + +/** + * Build a {@link Vault}-backed auth state under `key`. Loads the existing blob (or + * seeds a fresh `initAuthCreds()` + empty key map when absent), and returns a live + * SignalKeyStore plus a `saveCreds` that both persist the full blob. + */ +export async function makeVaultAuthState(vault: Vault, key: string): Promise { + const blob = await loadBlob(vault, key); + const creds = blob.creds; + const keys: KeyMap = blob.keys; + + const persist = async (): Promise => { + await vault.set(key, JSON.stringify({ creds, keys }, BufferJSON.replacer)); + }; + + const state: AuthenticationState = { + creds, + keys: { + get: async (type: T, ids: string[]) => { + const category = keys[type] ?? {}; + const result: { [id: string]: SignalDataTypeMap[T] } = {}; + for (const id of ids) { + let value = category[id]; + if (value !== undefined && value !== null) { + if (type === "app-state-sync-key") { + value = proto.Message.AppStateSyncKeyData.fromObject( + value as Record, + ); + } + result[id] = value as SignalDataTypeMap[T]; + } + } + return result; + }, + set: async (data: SignalDataSet) => { + for (const category in data) { + const entries = data[category as keyof SignalDataSet]; + if (entries === undefined) continue; + let bucket = keys[category]; + if (bucket === undefined) { + bucket = {}; + keys[category] = bucket; + } + for (const id in entries) { + const value = entries[id]; + if (value === null || value === undefined) { + delete bucket[id]; + } else { + bucket[id] = value; + } + } + } + await persist(); + }, + }, + }; + + return { state, saveCreds: persist }; +} + +/** Load + deserialize the blob, or seed a fresh one when the vault has no entry yet. */ +async function loadBlob(vault: Vault, key: string): Promise { + let raw: string; + try { + raw = await vault.get(key); + } catch (error) { + if (isNotFound(error)) { + return { creds: initAuthCreds(), keys: {} }; + } + throw error; + } + const parsed = JSON.parse(raw, BufferJSON.reviver) as Partial; + return { + creds: parsed.creds ?? initAuthCreds(), + keys: parsed.keys ?? {}, + }; +} diff --git a/packages/channel-whatsapp-web/tsconfig.json b/packages/channel-whatsapp-web/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/packages/channel-whatsapp-web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/vesper-cli/package.json b/packages/vesper-cli/package.json index e883d71..d7349cd 100644 --- a/packages/vesper-cli/package.json +++ b/packages/vesper-cli/package.json @@ -10,5 +10,8 @@ "@vesper/core": "workspace:*", "@vesper/pipelines": "workspace:*", "@vesper/ui": "workspace:*" + }, + "optionalDependencies": { + "@vesper/channel-whatsapp-web": "workspace:*" } } diff --git a/packages/vesper-cli/src/commands/daemon-run.ts b/packages/vesper-cli/src/commands/daemon-run.ts index db823da..79004b0 100644 --- a/packages/vesper-cli/src/commands/daemon-run.ts +++ b/packages/vesper-cli/src/commands/daemon-run.ts @@ -19,6 +19,7 @@ import { loadConfig, saveConfig } from "../config.ts"; import { buildChannelRegistry, makeChannelSink } from "../connections-wiring.ts"; import { removePidFile, resolveDaemonState, writePidFile } from "../daemon-lifecycle.ts"; import type { Command } from "../dispatch.ts"; +import { loadOptionalChannels } from "../optional-channels.ts"; import { PairingCoordinator } from "../pairing-coordinator.ts"; import { dbPath, pidPath, runDir, socketPath, uiPort } from "../paths.ts"; import { dim, green, line, yellow } from "../ui.ts"; @@ -95,6 +96,9 @@ export const daemonRunCommand: Command = { // messages bridge to the chatbot's EXISTING run path (see makeChannelSink); the // loops start AFTER the UI (their POST target) is listening. const vault = new KeychainVault(); + // Register opt-in channel packages (e.g. WhatsApp-Web via Baileys) BEFORE building the + // registry so a paired one starts, and so the pairing coordinator + UI report it available. + const optionalChannels = await loadOptionalChannels(); const channels = await buildChannelRegistry({ connections: config.connections, vault, @@ -142,6 +146,9 @@ export const daemonRunCommand: Command = { if (channels.runningIds.length > 0) { line(dim(` channels: ${channels.runningIds.join(", ")}`)); } + if (optionalChannels.length > 0) { + line(dim(` optional: ${optionalChannels.join(", ")} (opt-in package loaded)`)); + } // Start the cron tick loop. The scheduler records per-task errors for any // task whose handler is not registered — pipelines are now loaded above. diff --git a/packages/vesper-cli/src/optional-channels.test.ts b/packages/vesper-cli/src/optional-channels.test.ts new file mode 100644 index 0000000..65df3ba --- /dev/null +++ b/packages/vesper-cli/src/optional-channels.test.ts @@ -0,0 +1,21 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { channelPluginById, unregisterChannelPlugin } from "@vesper/core"; +import { loadOptionalChannels } from "./optional-channels.ts"; + +beforeEach(() => unregisterChannelPlugin("whatsapp-web")); +afterEach(() => unregisterChannelPlugin("whatsapp-web")); + +describe("loadOptionalChannels", () => { + test("resolves + registers the opt-in WhatsApp-Web plugin (lazy, self-driving)", async () => { + // Not a built-in: invisible until the opt-in package is loaded. + expect(channelPluginById("whatsapp-web")).toBeUndefined(); + + const registered = await loadOptionalChannels(); + expect(registered).toContain("whatsapp-web"); + + const plugin = channelPluginById("whatsapp-web"); + expect(plugin?.id).toBe("whatsapp-web"); + expect(plugin?.pairable).toBe(true); + expect(plugin?.pairingNeedsInbound).toBe(false); + }); +}); diff --git a/packages/vesper-cli/src/optional-channels.ts b/packages/vesper-cli/src/optional-channels.ts new file mode 100644 index 0000000..d77d36f --- /dev/null +++ b/packages/vesper-cli/src/optional-channels.ts @@ -0,0 +1,45 @@ +/** + * Lazy registration of OPT-IN channel plugins that live in separate packages, so + * `@vesper/core` and `@vesper/cli` stay statically dependency-free of heavy/optional + * channel SDKs. The daemon calls this once at boot; a package that is not installed + * (or fails to load) is simply skipped and its channel stays `available: false`. + * + * WhatsApp-Web (Baileys) is the first such package. The dynamic import uses a VARIABLE + * specifier on purpose: it keeps the package out of the static module graph (tsc + the + * compiled-binary bundler), so the heavy dependency is pulled in ONLY when present. + */ + +import { type ChannelPlugin, registerChannelPlugin } from "@vesper/core"; + +/** Optional channel packages to attempt to load, by module specifier + export name. */ +const OPTIONAL_CHANNELS: ReadonlyArray<{ readonly spec: string; readonly exportName: string }> = [ + { spec: "@vesper/channel-whatsapp-web", exportName: "whatsappWebPlugin" }, +]; + +/** Best-effort: register every installed optional channel plugin. Never throws. */ +export async function loadOptionalChannels(): Promise { + const registered: string[] = []; + for (const { spec, exportName } of OPTIONAL_CHANNELS) { + try { + const mod = (await import(spec)) as Record; + const plugin = mod[exportName]; + if (isChannelPlugin(plugin)) { + registerChannelPlugin(plugin); + registered.push(plugin.id); + } + } catch { + // Package not installed or failed to load — the channel stays unavailable. Fine. + } + } + return registered; +} + +/** Narrow an unknown module export to a {@link ChannelPlugin} (id + build factory). */ +function isChannelPlugin(value: unknown): value is ChannelPlugin { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + typeof (value as { build?: unknown }).build === "function" + ); +} diff --git a/packages/vesper-cli/src/pairing-coordinator.ts b/packages/vesper-cli/src/pairing-coordinator.ts index 64096da..7a55ec3 100644 --- a/packages/vesper-cli/src/pairing-coordinator.ts +++ b/packages/vesper-cli/src/pairing-coordinator.ts @@ -106,30 +106,43 @@ export class PairingCoordinator { const vaultKey = conn?.vaultKey ?? descriptor.vaultKeys[0]; if (vaultKey === undefined) return errorSession(`channel "${id}" declares no vault key`); - // Reuse the running handler if the daemon already receives this channel; otherwise - // build a transient one and feed only the pairing listeners for the pairing window. - let handler: ChannelHandler | undefined = this.#deps.registry.byId(id as ChannelId); + const plugin = channelPluginById(id); + const running = this.#deps.registry.byId(id as ChannelId); + if (plugin === undefined && running === undefined) { + return errorSession(`channel "${id}" has no handler yet`); + } + // SELF-DRIVING channels (e.g. WhatsApp-Web) establish auth via the scan itself and drive + // their own socket, so they skip the authenticate precondition + the inbound multiplex. + // CHAT-LINK channels (Telegram/Discord) watch the daemon's single inbound stream for the + // nonce, reusing the running receiver or a transient one. + const needsInbound = plugin?.pairingNeedsInbound !== false; + const buildOpts = { + granted: CHANNEL_GRANTS, + vaultKey, + allowedHosts: conn?.allowedHosts ?? descriptor.allowedHosts, + ...(conn?.params !== undefined ? { params: conn.params } : {}), + ...(this.#deps.fetchFn !== undefined ? { fetchFn: this.#deps.fetchFn } : {}), + }; + + let handler: ChannelHandler; let transient: Stoppable | undefined; - if (handler === undefined) { - const plugin = channelPluginById(id); + if (needsInbound && running !== undefined) { + handler = running; + } else { if (plugin === undefined) return errorSession(`channel "${id}" has no handler yet`); - const built = plugin.build({ - granted: CHANNEL_GRANTS, - vaultKey, - allowedHosts: conn?.allowedHosts ?? descriptor.allowedHosts, - ...(conn?.params !== undefined ? { params: conn.params } : {}), - ...(this.#deps.fetchFn !== undefined ? { fetchFn: this.#deps.fetchFn } : {}), - }); - try { - await built.authenticate(this.#deps.vault); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - return errorSession(`cannot authenticate "${id}": ${reason}`); + const built = plugin.build(buildOpts); + if (needsInbound) { + try { + await built.authenticate(this.#deps.vault); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + return errorSession(`cannot authenticate "${id}": ${reason}`); + } + transient = built.receive(async (message) => { + this.#notify(message); + }); } handler = built; - transient = built.receive(async (message) => { - this.#notify(message); - }); } if (!isPairable(handler)) { @@ -141,7 +154,9 @@ export class PairingCoordinator { const inner = handler.startPairing({ vault: this.#deps.vault, - subscribeInbound: (on) => this.#subscribe(on), + ...(needsInbound + ? { subscribeInbound: (on: (m: InboundMessage) => void) => this.#subscribe(on) } + : {}), }); const onUpdate = (update: PairingUpdate): Promise => this.#onUpdate(id, vaultKey, update); @@ -165,23 +180,33 @@ export class PairingCoordinator { async #onUpdate(id: string, vaultKey: string, update: PairingUpdate): Promise { if (update.status === "linked") { - if (update.chatId !== undefined) await this.#persistLinked(id, vaultKey, update.chatId); - this.#record("connection_paired", { channel: id, vaultKey, chatId: update.chatId }); + // Enable the channel on link. Chat-link channels carry the captured chat id; + // self-driving channels (WhatsApp-Web) link the account with no chat id here. + await this.#persistLinked(id, vaultKey, update.chatId); + this.#record("connection_paired", { + channel: id, + vaultKey, + ...(update.chatId !== undefined ? { chatId: update.chatId } : {}), + }); } else if (update.status === "error" || update.status === "expired") { this.#record("connection_pairing_failed", { channel: id, outcome: update.status }); } } - /** Persist the captured chat id as `params.defaultChatId` and enable the channel. */ - async #persistLinked(id: string, vaultKey: string, chatId: string): Promise { + /** Enable the channel on link, recording the captured chat id (if any) as the default target. */ + async #persistLinked(id: string, vaultKey: string, chatId?: string): Promise { const config = await this.#deps.load(); const descriptor = channelById(id); const existing = config.connections?.[id]; + const params = { + ...existing?.params, + ...(chatId !== undefined ? { defaultChatId: chatId } : {}), + }; const conn: ConnectionConfig = { enabled: true, vaultKey, allowedHosts: existing?.allowedHosts ?? descriptor?.allowedHosts ?? [], - params: { ...existing?.params, defaultChatId: chatId }, + ...(Object.keys(params).length > 0 ? { params } : {}), }; await this.#deps.save(withConnection(config, id, conn)); } diff --git a/packages/vesper-core/src/connections/catalog.ts b/packages/vesper-core/src/connections/catalog.ts index d9e3ad6..7696b33 100644 --- a/packages/vesper-core/src/connections/catalog.ts +++ b/packages/vesper-core/src/connections/catalog.ts @@ -40,6 +40,20 @@ export const CHANNEL_CATALOG: readonly ChannelDescriptor[] = [ docsUrl: "https://developers.facebook.com/docs/whatsapp/cloud-api", status: "ready", }, + { + id: "whatsapp-web", + displayName: "WhatsApp (personal)", + // Personal-account linking via the WhatsApp-Web protocol (Baileys). Handler ships + // ONLY when the opt-in @vesper/channel-whatsapp-web package is installed + registered, + // so `available` (not `status`) is the honest gate. + transport: "qr-web", + // Baileys drives its own WhatsApp-Web WebSocket; these hosts are informational + // (egress is not routed through allowlistedFetch for this transport). + allowedHosts: ["web.whatsapp.com", "g.whatsapp.com"], + vaultKeys: ["whatsapp_web_session"], + docsUrl: "https://baileys.wiki/docs/intro/", + status: "ready", + }, { id: "signal", displayName: "Signal", diff --git a/packages/vesper-core/src/connections/index.ts b/packages/vesper-core/src/connections/index.ts index 2eff0fe..adad63e 100644 --- a/packages/vesper-core/src/connections/index.ts +++ b/packages/vesper-core/src/connections/index.ts @@ -33,6 +33,8 @@ export { type ChannelBuildOptions, type ChannelPlugin, channelPluginById, + registerChannelPlugin, + unregisterChannelPlugin, } from "./plugins.ts"; export { ChannelRegistry } from "./registry.ts"; export { diff --git a/packages/vesper-core/src/connections/plugins.ts b/packages/vesper-core/src/connections/plugins.ts index bf41148..1a1ab74 100644 --- a/packages/vesper-core/src/connections/plugins.ts +++ b/packages/vesper-core/src/connections/plugins.ts @@ -37,6 +37,14 @@ export interface ChannelPlugin { readonly id: ChannelId; /** True when {@link build} returns a handler that also implements `Pairable` (QR onboarding). */ readonly pairable?: boolean; + /** + * Whether pairing observes the daemon's inbound stream (default `true`, for chat-link + * channels like Telegram/Discord that watch for a `/start ` message). Set `false` + * for SELF-DRIVING pairing (e.g. WhatsApp-Web, where the handler drives its own socket and + * establishes auth via the scan itself) — the coordinator then skips the authenticate + * precondition and the transient receive loop. + */ + readonly pairingNeedsInbound?: boolean; build(opts: ChannelBuildOptions): ChannelHandler; } @@ -79,7 +87,25 @@ export const CHANNEL_PLUGINS: readonly ChannelPlugin[] = [ }, ]; -/** Look up a channel plugin by id, or undefined when no handler ships for it. */ +/** + * Runtime-registered OPTIONAL plugins (e.g. the opt-in `@vesper/channel-whatsapp-web` + * package). Kept separate from the built-ins so core ships ZERO optional dependencies — the + * daemon/CLI lazily imports the package at boot and registers its plugin here, and core never + * imports it. A channel with no built-in and no registered plugin reports `available: false`. + */ +const REGISTERED_PLUGINS = new Map(); + +/** Register an optional channel plugin at runtime (idempotent; a re-register replaces). */ +export function registerChannelPlugin(plugin: ChannelPlugin): void { + REGISTERED_PLUGINS.set(plugin.id, plugin); +} + +/** Remove a runtime-registered optional plugin (test/teardown helper). */ +export function unregisterChannelPlugin(id: string): void { + REGISTERED_PLUGINS.delete(id); +} + +/** Look up a channel plugin by id — built-ins first, then runtime-registered optionals. */ export function channelPluginById(id: string): ChannelPlugin | undefined { - return CHANNEL_PLUGINS.find((plugin) => plugin.id === id); + return CHANNEL_PLUGINS.find((plugin) => plugin.id === id) ?? REGISTERED_PLUGINS.get(id); } diff --git a/packages/vesper-core/src/connections/types.ts b/packages/vesper-core/src/connections/types.ts index 4610447..faf6643 100644 --- a/packages/vesper-core/src/connections/types.ts +++ b/packages/vesper-core/src/connections/types.ts @@ -11,13 +11,13 @@ import type { Vault } from "../vault/index.ts"; /** The messaging channels Vesper knows about. Catalog-only; no arbitrary channels. */ -export type ChannelId = "telegram" | "discord" | "whatsapp" | "signal"; +export type ChannelId = "telegram" | "discord" | "whatsapp" | "whatsapp-web" | "signal"; /** Whether a channel is BUILT in v1 or declared-but-deferred (catalog + tutorial only). */ export type ChannelStatus = "ready" | "deferred"; /** The transport a channel handler uses to reach its service. */ -export type ChannelTransport = "long-poll" | "webhook" | "bot-api" | "local-cli"; +export type ChannelTransport = "long-poll" | "webhook" | "bot-api" | "local-cli" | "qr-web"; /** An outbound message the chatbot asks a handler to deliver. */ export interface OutboundIntent {