diff --git a/AGENTS.md b/AGENTS.md index 1e37766..e36cec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ### Issue tracker -Issues live in GitHub Issues for `devlooped/whatsbox`. See `docs/agents/issue-tracker.md`. +Issues live in GitHub Issues for `devlooped/Inbox`. See `docs/agents/issue-tracker.md`. ### Triage labels diff --git a/changelog.md b/changelog.md index cb4b23d..ab49f16 100644 --- a/changelog.md +++ b/changelog.md @@ -1,48 +1,48 @@ # Changelog -## [v0.1.5](https://github.com/devlooped/whatsbox/tree/v0.1.5) (2026-08-20) +## [v0.1.5](https://github.com/devlooped/Inbox/tree/v0.1.5) (2026-08-20) -[Full Changelog](https://github.com/devlooped/whatsbox/compare/v0.1.4...v0.1.5) +[Full Changelog](https://github.com/devlooped/Inbox/compare/v0.1.4...v0.1.5) :sparkles: Implemented enhancements: -- Subscribe is JID-only; fix directory identity and reply quotes [\#10](https://github.com/devlooped/whatsbox/pull/10) (@kzu) +- Subscribe is JID-only; fix directory identity and reply quotes [\#10](https://github.com/devlooped/Inbox/pull/10) (@kzu) :twisted_rightwards_arrows: Merged: -- Pack WhatsDemo as the wd tool with pointer and RID packages [\#12](https://github.com/devlooped/whatsbox/pull/12) (@kzu) +- Pack WhatsDemo as the wd tool with pointer and RID packages [\#12](https://github.com/devlooped/Inbox/pull/12) (@kzu) -## [v0.1.4](https://github.com/devlooped/whatsbox/tree/v0.1.4) (2026-08-19) +## [v0.1.4](https://github.com/devlooped/Inbox/tree/v0.1.4) (2026-08-19) -[Full Changelog](https://github.com/devlooped/whatsbox/compare/v0.1.3...v0.1.4) +[Full Changelog](https://github.com/devlooped/Inbox/compare/v0.1.3...v0.1.4) -## [v0.1.3](https://github.com/devlooped/whatsbox/tree/v0.1.3) (2026-08-19) +## [v0.1.3](https://github.com/devlooped/Inbox/tree/v0.1.3) (2026-08-19) -[Full Changelog](https://github.com/devlooped/whatsbox/compare/v0.1.2...v0.1.3) +[Full Changelog](https://github.com/devlooped/Inbox/compare/v0.1.2...v0.1.3) :bug: Fixed bugs: -- Stop double-emitting the first pairing QR [\#8](https://github.com/devlooped/whatsbox/pull/8) (@kzu) +- Stop double-emitting the first pairing QR [\#8](https://github.com/devlooped/Inbox/pull/8) (@kzu) -## [v0.1.2](https://github.com/devlooped/whatsbox/tree/v0.1.2) (2026-08-19) +## [v0.1.2](https://github.com/devlooped/Inbox/tree/v0.1.2) (2026-08-19) -[Full Changelog](https://github.com/devlooped/whatsbox/compare/v0.1.1...v0.1.2) +[Full Changelog](https://github.com/devlooped/Inbox/compare/v0.1.1...v0.1.2) :sparkles: Implemented enhancements: -- Allow setting the WhatsApp linked-device name on initialize [\#5](https://github.com/devlooped/whatsbox/pull/5) (@kzu) +- Allow setting the WhatsApp linked-device name on initialize [\#5](https://github.com/devlooped/Inbox/pull/5) (@kzu) :twisted_rightwards_arrows: Merged: -- Expand markdown includes in package readme [\#7](https://github.com/devlooped/whatsbox/pull/7) (@kzu) +- Expand markdown includes in package readme [\#7](https://github.com/devlooped/Inbox/pull/7) (@kzu) -## [v0.1.1](https://github.com/devlooped/whatsbox/tree/v0.1.1) (2026-08-19) +## [v0.1.1](https://github.com/devlooped/Inbox/tree/v0.1.1) (2026-08-19) -[Full Changelog](https://github.com/devlooped/whatsbox/compare/7f2763626f62021d3a2070b22dde229fd8522363...v0.1.1) +[Full Changelog](https://github.com/devlooped/Inbox/compare/7f2763626f62021d3a2070b22dde229fd8522363...v0.1.1) :sparkles: Implemented enhancements: -- Create C\# client around the native whatsbox CLI [\#4](https://github.com/devlooped/whatsbox/pull/4) (@kzu) +- Create C\# client around the native whatsbox CLI [\#4](https://github.com/devlooped/Inbox/pull/4) (@kzu) diff --git a/docs/INBOX.md b/docs/INBOX.md index d38c247..6831380 100644 --- a/docs/INBOX.md +++ b/docs/INBOX.md @@ -1,11 +1,11 @@ -# Inbox Protocol +# Inbox Client Protocol (ICP) **Status:** v0.1 (draft, session-locked with WhatsBox) **Wire version:** `"0.1"` **Transport:** JSON-RPC 2.0, NDJSON over stdio **Reference profile:** WhatsApp / [`docs/WHATSBOX.md`](WHATSBOX.md) -Inbox is a **single client-facing JSON-RPC 2.0 pub/sub bus** for local companion processes. One binary owns one messaging-product session and exposes it over stdin/stdout. Clients subscribe to chats and two system topics, send a small set of actions, and receive live events. It is **not** an archive, a search engine, or a product CLI. +Inbox Client Protocol (**ICP**) is a **single client-facing JSON-RPC 2.0 pub/sub bus** for local companion processes. One binary owns one messaging-product session and exposes it over stdin/stdout. Clients subscribe to chats and two system topics, send a small set of actions, and receive live events. It is **not** an archive, a search engine, or a product CLI. A client implements the methods, events, store/files rules, and error tokens in this document **once**. Product differences appear only as: @@ -1425,9 +1425,9 @@ Recommended for new unifying clients. Gateways MUST still ignore unknown `contex ## 23. Relationship to WhatsBox -This document is the **protocol**. [`docs/WHATSBOX.md`](WHATSBOX.md) is the **WhatsApp profile**: store layout, LID canonicalization, HistorySync headers, ContextInfo `remoteJid`, group `MarkRead` participant, and the shipped `whatsbox` binary. It does not restate the method table, event envelope, or error tokens. +This document is **Inbox Client Protocol (ICP)** (also the managed `Inbox` client). [`docs/WHATSBOX.md`](WHATSBOX.md) is the **WhatsApp profile** for the native `whatsbox` adapter in this Inbox repository: store layout, LID canonicalization, HistorySync headers, ContextInfo `remoteJid`, group `MarkRead` participant, and the shipped `whatsbox` binary. The `WhatsBox` NuGet is the managed host on top of that adapter. This spec does not restate the method table, event envelope, or error tokens. -A `whatsbox` binary is a conformant Inbox Protocol implementation when it speaks this envelope (including `product`, `identity`, and `capabilities` on `initialize` / `session.status`) and maps WhatsApp as in WHATSBOX.md + RFC-1. +A `whatsbox` binary is a conformant ICP implementation when it speaks this envelope (including `product`, `identity`, and `capabilities` on `initialize` / `session.status`) and maps WhatsApp as in WHATSBOX.md + RFC-1. Wire version stays **`"0.1"`** so one codec spans the family. diff --git a/docs/WHATSBOX.md b/docs/WHATSBOX.md index b85de76..e082d7a 100644 --- a/docs/WHATSBOX.md +++ b/docs/WHATSBOX.md @@ -1,17 +1,17 @@ # WhatsBox -WhatsBox is **an Inbox Protocol implementation for WhatsApp**. +WhatsBox is the **WhatsApp adapter** for Inbox Client Protocol (ICP): native binary `whatsbox`, managed NuGet `WhatsBox`. **Status:** v0.1 (session-locked) **License (product):** MIT **Language:** Go **Library:** [whatsmeow](https://github.com/tulir/whatsmeow) -whatsbox is the **WhatsApp implementation** of [Inbox Protocol](INBOX.md): a local linked-device companion that owns one WhatsApp session and exposes it as a JSON-RPC 2.0 pub/sub bus over stdio. +`whatsbox` is the native WhatsApp adapter in this Inbox repository: a local linked-device companion that owns one WhatsApp session and exposes it as a JSON-RPC 2.0 pub/sub bus over stdio. The `WhatsBox` NuGet is the managed host on top of that adapter. Wire, methods, events, files, errors, client-once rules, and capabilities are specified in **INBOX.md**. This document is the WhatsApp profile: how that envelope maps onto WhatsApp Web (LIDs, QR pairing, ContextInfo, HistorySync headers, store layout). It does **not** restate the method table. -`external/whatsmeow` and `external/wacli` in this workspace are **reference only**; whatsbox is a greenfield repo and does not share databases, packages, or command surface with wacli. +`external/whatsmeow` and `external/wacli` in this workspace are **reference only**; the adapter does not share databases, packages, or command surface with wacli. --- diff --git a/osmfeula.txt b/osmfeula.txt index 414beb9..6444457 100644 --- a/osmfeula.txt +++ b/osmfeula.txt @@ -2,7 +2,7 @@ End User License Agreement This Open Source Maintenance Fee Agreement ("Agreement") is a legal agreement between you ("User") and Devlooped ("Project") for the use of -whatsbox ("Software"), an open source software project licensed under +Inbox ("Software"), an open source software project licensed under the MIT License ("OSI License"), an OSI-approved open source license. Project offers a Binary Release of the Software to Users in exchange for a maintenance fee ("Fee"). "Binary Release" refers to pre-compiled executable diff --git a/readme.md b/readme.md index ee487c6..b2f68c8 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -![Icon](docs/logo.png) whatsbox +![Icon](docs/logo.png) Inbox ============ [![Version](https://img.shields.io/nuget/vpre/WhatsBox.svg?color=royalblue)](https://www.nuget.org/packages/WhatsBox) @@ -19,89 +19,49 @@ OSMF tier. A single fee covers all of [Devlooped packages](https://www.nuget.org --- - -whatsbox is **an Inbox Protocol implementation for WhatsApp**: one process -owns a linked-device session and exposes it as a **JSON-RPC 2.0 pub/sub bus -over stdio**. Clients subscribe to chats (and two system topics), send a -small set of actions, and receive live events. It is not an archive, a -search engine, or a WhatsApp CLI. - -The WhatsApp connection is powered by [whatsmeow](https://github.com/tulir/whatsmeow). - -Use the **managed client** from .NET, or speak the **native protocol** from any -language that can spawn a process and exchange newline-delimited JSON. - -## Managed Client - -The [`WhatsBox`](https://www.nuget.org/packages/WhatsBox) package is a typed -.NET host for the native `whatsbox` sidecar plus the managed **Inbox Protocol** -client (`InboxClient`). `PackageReference` it, then publish for your RID — -the matching native binary is restored and copied next to the app. +# Inbox Client Protocol + +**Inbox Client Protocol (ICP)** is a **JSON-RPC 2.0 pub/sub bus over stdio** for local +companion processes. One binary owns one messaging-product session. Clients +subscribe to chats (and two system topics), send a small set of actions, and +receive live events. It is not an archive, a search engine, or a product CLI. + +The [`Inbox`](https://www.nuget.org/packages/Inbox) package is the managed +client (`InboxClient`) for **any** implementation of that protocol. Spawn a +box, exchange newline-delimited JSON, and keep the process alive for the life +of the session. Product differences are opaque topic strings, `$session` auth +payloads, and `product` / `identity` / `capabilities` on `initialize`. There +are no per-product method names. + +Wire version `"0.1"`: methods, events (`contents[]` on chat topics), files, +errors, and capabilities — [`docs/INBOX.md`](docs/INBOX.md). ```xml - -``` - -Target framework: `net10.0`. The managed surface is AOT-compatible (source-generated JSON). - -### Packaging and publish - -`WhatsBox` is a **pointer package**: it ships `WhatsBox.dll` plus a -`runtime.json` that maps each runtime identifier to a RID-only package. - -| Package | Contents | -|---|---| -| `WhatsBox` | Managed API (`WhatsBox.dll`) and `runtime.json` | -| `WhatsBox.win-x64` / `.win-arm64` / `.linux-x64` / `.linux-arm64` / `.osx-x64` / `.osx-arm64` | Native `whatsbox` / `whatsbox.exe` under `runtimes/{rid}/native/` | - -You only reference `WhatsBox`. Restore and `dotnet publish -r ` pull the -matching `WhatsBox.{rid}` package automatically. The sidecar lands next to the -app (`AppContext.BaseDirectory`); `WhatsBoxClient` starts it from there — never -from the current working directory. The pointer package depends on [`Inbox`](https://www.nuget.org/packages/Inbox) -(`InboxClient`); Inbox's RID packing targets are **not** transitive. - -Adapters for other products PackageReference `Inbox` directly (or ProjectReference -it and import `Inbox.targets`) so pointer + `dotnet pack -r` packaging is shared. - -```bash -dotnet add package WhatsBox -dotnet publish -c Release -r win-x64 -``` - -Do not add `WhatsBox.win-x64` (or any other RID package) by hand. Do not treat -this as a .NET tool (`PackAsTool`); it is a `PackageReference` library plus a -native asset. - -The companion REPL is a separate tool package (`wd`) with the -same pointer + RID split: - -```bash -dotnet tool install -g wd -wd + ``` -Supported RIDs: `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, -`osx-arm64`. +Target framework: `net10.0`. The managed surface is AOT-compatible +(source-generated JSON). Adapters that ship a native sidecar PackageReference +`Inbox` so pointer + +`dotnet pack -r` packaging is shared. -### API +## InboxClient -`WhatsBoxClient` owns the child process, turns unary JSON-RPC methods into -`Task`, and exposes a single-consumer pull stream of typed events. -Disposing the client stops the sidecar. +`InboxClient` turns unary JSON-RPC methods into `Task` and exposes a +single-consumer pull stream of typed `InboxEvent`s. Construct it over an +already-started NDJSON `TextReader`+`TextWriter` pair (the box’s stdout and +stdin). Disposing the client completes `Events`. Start consuming `Events` **before** (or concurrently with) a connecting -`InitializeAsync`. Pairing QR codes arrive as events while that call is still -waiting for a scan. +`InitializeAsync`. Auth events (QR, OAuth, device-code, …) arrive on +`$session` while that call is still waiting. -```csharp -using WhatsBox; +WhatsBox-shaped illustration — the same types work for any box: -var store = Path.GetFullPath("whatsbox-store"); -var files = Path.GetFullPath("whatsbox-files"); -Directory.CreateDirectory(store); -Directory.CreateDirectory(files); +```csharp +using Inbox; -await using var box = new WhatsBoxClient(); +await using var box = new InboxClient(stdout, stdin); var pump = Task.Run(async () => { @@ -110,7 +70,7 @@ var pump = Task.Run(async () => switch (ev) { case SessionQr qr: - // Render qr.Code as a QR image and scan it in WhatsApp → Linked devices. + // Product auth: WhatsBox emits qr; other boxes may emit oauth, … Console.WriteLine(qr.Code); break; case SessionOnline online: @@ -122,7 +82,7 @@ var pump = Task.Run(async () => case DirectoryReady: var page = await box.ListDirectoryAsync(new DirectoryListOptions { Kind = "user" }); foreach (var row in page.Items) - Console.WriteLine($"{row.Name ?? row.Topic} {row.Pn}"); + Console.WriteLine($"{row.Name ?? row.Topic}"); break; case DirectoryUpsert upsert: Console.WriteLine($"directory: {upsert.Name ?? upsert.Jid}"); @@ -146,20 +106,18 @@ var session = await box.InitializeAsync(new InitializeOptions if (session.Status == "online") { - var listed = await box.ListDirectoryAsync(new DirectoryListOptions { Query = "+15551234567" }); + var listed = await box.ListDirectoryAsync(new DirectoryListOptions { Query = "alice" }); var chat = listed.Items[0].Topic; await box.SubscribeAsync([chat]); - await box.SendAsync(chat, text: "hello from whatsbox"); + await box.SendAsync(chat, text: "hello from Inbox"); } await pump; ``` `InitializeAsync(store)` is the short form: no files, no extra subscriptions, -no connect. The linked-device name defaults to `whatsbox on {machine}`. Pass -`InitializeOptions` when you want blobs, initial topics, a custom -`DeviceName`, or `Connect = true` (implicit `session.connect`, and implicit -QR pairing when the store is new). +no connect. Pass `InitializeOptions` for blobs, initial topics, or +`Connect = true` (implicit `session.connect`). | Method | RPC | Result | |---|---|---| @@ -169,20 +127,19 @@ QR pairing when the store is new). | `DisconnectAsync` | `session.disconnect` | `SessionSnapshot` | | `LogoutAsync` | `session.logout` | `SessionSnapshot` (`new`) | | `StatusAsync` | `session.status` | `SessionSnapshot` | -| `SubscribeAsync` / `UnsubscribeAsync` | `subscribe` / `unsubscribe` | `TopicsResult` (canonical JIDs) | +| `SubscribeAsync` / `UnsubscribeAsync` | `subscribe` / `unsubscribe` | `TopicsResult` (canonical topics) | | `ListDirectoryAsync` | `directory.list` | `DirectoryListResult` | | `GetDirectoryAsync` | `directory.get` | `DirectoryRow` | | `SendAsync` / `ReactAsync` | `messages.send` | `SendResult` (`Id`, canonical `Topic`) | | `ReadAsync` | `messages.read` | `ReadResult` | -`SessionSnapshot.Status` is `new` (never paired), `offline` (keys on disk, -socket down), or `online`. `Me` is the paired LID and is omitted when `new`. +`SessionSnapshot.Status` is `new` (never authenticated), `offline` (keys on +disk, socket down), or `online`. `Me` is the authenticated identity and is +omitted when `new`. -`SubscribeAsync` / `UnsubscribeAsync` take canonical JIDs (LID, group, or -PN JID). Resolve names and phone numbers with `ListDirectoryAsync` first. -`SendAsync`, `ReadAsync`, and `GetDirectoryAsync` still accept a LID, a -phone-number JID, or a phone number (`+15551234567` or digits). Results and -event topics are always **canonical** (LID or group JID) once a LID is known. +`SubscribeAsync` / `UnsubscribeAsync` take **canonical topics** only. Resolve +names with `ListDirectoryAsync` first. Results and event topics are always +canonical once the box knows them. Send text (sugar), a file under `files`, a reply, and/or a reaction: @@ -198,75 +155,218 @@ await box.ReactAsync(chat, target: id, by, "👍"); ``` `by` is required on reply, react, and `ReadAsync` — copy it from the inbound -event. Use `"me"` when targeting your own message. In 1:1 the sidecar ignores -`by`; in groups every id in that call must share that author. Mark-read is -never automatic. +event. Use `"me"` when targeting your own message. Mark-read is never +automatic. -RPC failures throw `WhatsRpcException` with the JSON-RPC `Code` and a stable +RPC failures throw `InboxRpcException` with the JSON-RPC `Code` and a stable `Token` (`not_initialized`, `files_required`, `not_found`, …). -### Events +stderr is logs only. It is never protocol. + +## Events and contents -`Events` is a single-consumer `IAsyncEnumerable`. Enumerate it +`Events` is a single-consumer `IAsyncEnumerable`. Enumerate it once. It completes when the child stdout ends or the client is disposed. | Type | Topic | Kind | |---|---|---| -| `SessionQr` | `$session` | `qr` — string to render as QR | +| `SessionQr` | `$session` | `qr` — string to render (WhatsBox illustration) | | `SessionPaired` | `$session` | `paired` | | `SessionPairError` | `$session` | `pair_error` | | `SessionOnline` / `SessionOffline` | `$session` | `online` / `offline` | | `SessionLoggedOut` | `$session` | `logged_out` | -| `SessionRemap` | `$session` | `remap` — subscription moved PN → LID | +| `SessionRemap` | `$session` | `remap` — subscription moved to a new canonical topic | | `SessionOverflow` | `$session` | `overflow` — per-topic queue dropped oldest | | `DirectoryUpsert` / `DirectoryRemove` / `DirectoryReady` | `$directory` | catalog changes | -| `ChatMessage` | chat JID | `message` — `Contents` (text, media, location, unknown); `Text` concatenates text parts | -| `ChatReaction` | chat JID | `reaction` — one reaction part | -| `ChatAck` | chat JID | `ack` — `delivered` / `read` / `played` | -| `ChatMeta` | chat JID | `meta` — join/leave/rename/… | - -Chat events share `Id`, `By` (`"me"` or a LID), `Handle` (`@username` when -known), `TopicName`, `ByName`, and `Contents`. There is no phone number on -chat events — look up `By` (or a 1:1 `Topic`) with `GetDirectoryAsync` when -you need `Pn`. - -### Host - -`new WhatsBoxClient()` starts `whatsbox` / `whatsbox.exe` from -`AppContext.BaseDirectory`. Use `WhatsBoxClient.Start(baseDirectory)` to -point at another folder, or construct from an already-started `WhatsBoxHost` -/ a raw NDJSON `TextReader`+`TextWriter` pair if you spawn the process +| `ChatMessage` | chat topic | `message` — `Contents` (text, media, location, unknown); `Text` concatenates text parts | +| `ChatReaction` | chat topic | `reaction` — one reaction part | +| `ChatAck` | chat topic | `ack` — `delivered` / `read` / `played` | +| `ChatMeta` | chat topic | `meta` — join/leave/rename/… | + +Chat events share `Id`, `By` (`"me"` or an opaque user id), `Handle` +(`@username` when known), `TopicName`, `ByName`, and `Contents`. Look up `By` +(or a 1:1 `Topic`) with `GetDirectoryAsync` for extra directory fields. + +Content parts: `text`, `image`, `video`, `audio`, `document`, `sticker`, +`location`, `unknown`, plus `reaction` / `ack` / `meta` on those kinds. +Blob parts carry a relative `Path` under `initialize.files`. + +## Implementing a box + +An implementation speaks this envelope on stdio (JSON-RPC 2.0, one object per +line, no batch arrays). Advertise `product`, `identity`, and `capabilities` on +`initialize` / `session.status`. Consumers construct `InboxClient` over that +process — they never type-parse topic / `by` strings. + +Suggested binaries (not normative): `whatsbox`, `discordbox`, `slackbox`, +`teamsbox`, `telegrambox`, `matrixbox`. Full method table, event shapes, and +error tokens: [`docs/INBOX.md`](docs/INBOX.md). + +# WhatsBox + +The native **`whatsbox` adapter** implements Inbox Client Protocol (ICP) for WhatsApp — +one process owns a linked-device session and exposes it on the bus. The +[`WhatsBox`](https://www.nuget.org/packages/WhatsBox) NuGet is the managed +host on top of that native adapter (it is not the protocol itself). Protocol, +events, and `InboxClient`: the [`Inbox`](https://www.nuget.org/packages/Inbox) +package. + +The WhatsApp connection is powered by [whatsmeow](https://github.com/tulir/whatsmeow); +clients never talk to whatsmeow directly. WhatsApp-specific mapping (LID +topics, QR pairing, ContextInfo quotes, HistorySync headers, +`attachments: "single"`) is [`docs/WHATSBOX.md`](docs/WHATSBOX.md). + +## Managed host + +`WhatsBoxClient` is an `InboxClient` that starts the native `whatsbox` / +`whatsbox.exe` sidecar. `PackageReference` it, then publish for your RID — +the matching native binary is restored and copied next to the app. + +```xml + +``` + +`WhatsBox` is a **pointer package**: it ships `WhatsBox.dll` plus a +`runtime.json` that maps each runtime identifier to a RID-only package. + +| Package | Contents | +|---|---| +| `WhatsBox` | Managed API (`WhatsBox.dll`) and `runtime.json` | +| `WhatsBox.win-x64` / `.win-arm64` / `.linux-x64` / `.linux-arm64` / `.osx-x64` / `.osx-arm64` | Native `whatsbox` / `whatsbox.exe` under `runtimes/{rid}/native/` | + +You only reference `WhatsBox`. Restore and `dotnet publish -r ` pull the +matching `WhatsBox.{rid}` package automatically. The sidecar lands next to the +app (`AppContext.BaseDirectory`); `WhatsBoxClient` starts it from there — never +from the current working directory. Inbox's RID packing targets are **not** +transitive. + +```bash +dotnet add package WhatsBox +dotnet publish -c Release -r win-x64 +``` + +Do not add `WhatsBox.win-x64` (or any other RID package) by hand. Do not treat +this as a .NET tool (`PackAsTool`); it is a `PackageReference` library plus a +native asset. + +The companion sample REPL is a separate tool package (`wd`, for WhatsBox Demo) with the +same pointer + RID split: + +```bash +ndnx wd +``` + +> We recommend using [`ndnx`](https://github.com/devlooped/ndnx) +> for fastest native-only execution. It's like dnx but native, with +> no .NET runtime/SDK dependency. + +Supported RIDs: `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, +`osx-arm64`. + +## QR pairing and store + +`new WhatsBoxClient()` starts the sidecar from `AppContext.BaseDirectory`. +Use `WhatsBoxClient.Start(baseDirectory)` to point at another folder, or +construct from an already-started `WhatsBoxHost` if you spawn the process yourself. -stderr is logs only (`Debug.WriteLine` when the client owns the host). It is -never protocol. +Start consuming `Events` **before** (or concurrently with) a connecting +`InitializeAsync`. Pairing QR codes arrive as `SessionQr` while that call is +still waiting for a scan. -## Native Protocol +```csharp +using WhatsBox; -The `whatsbox` binary is a local companion process. Spawn it, speak -**JSON-RPC 2.0** (NDJSON) on stdio, and keep the process alive for the life -of the session. The wire is **Inbox Protocol** [`docs/INBOX.md`](docs/INBOX.md) -version `"0.1"`: methods, events (`contents[]` on chat topics), files, -errors, and capabilities. WhatsApp-specific mapping (LID topics, QR pairing, -ContextInfo quotes, HistorySync headers, `attachments: "single"`) is -[`docs/WHATSBOX.md`](docs/WHATSBOX.md). +var store = Path.GetFullPath("whatsbox-store"); +var files = Path.GetFullPath("whatsbox-files"); +Directory.CreateDirectory(store); +Directory.CreateDirectory(files); + +await using var box = new WhatsBoxClient(); + +var pump = Task.Run(async () => +{ + await foreach (var ev in box.Events) + { + switch (ev) + { + case SessionQr qr: + // Render qr.Code as a QR image and scan it in WhatsApp → Linked devices. + Console.WriteLine(qr.Code); + break; + case SessionOnline online: + Console.WriteLine($"online as {online.Me}"); + break; + case ChatMessage msg: + Console.WriteLine($"{msg.ByName ?? msg.By}: {msg.Text}"); + if (msg.Id is not null) + await box.ReadAsync(msg); + break; + } + } +}); + +var session = await box.InitializeAsync(new InitializeOptions +{ + Store = store, + Files = files, + Subscribe = ["$directory"], + Connect = true, +}); + +if (session.Status == "online") +{ + var listed = await box.ListDirectoryAsync(new DirectoryListOptions { Query = "+15551234567" }); + var chat = listed.Items[0].Topic; + await box.SubscribeAsync([chat]); + await box.SendAsync(chat, text: "hello from whatsbox"); +} + +await pump; +``` + +`InitializeAsync(store)` is the short form. The linked-device name defaults to +`whatsbox on {machine}`. Pass `InitializeOptions` when you want blobs, initial +topics, a custom `DeviceName`, or `Connect = true` (implicit `session.connect`, +and implicit QR pairing when the store is new). + +There is **no default store**. One process, one store, one WhatsApp session. +`SubscribeAsync` / `UnsubscribeAsync` take canonical JIDs (LID, group, or PN +JID). Resolve names and phone numbers with `ListDirectoryAsync` first. +`SendAsync`, `ReadAsync`, and `GetDirectoryAsync` still accept a LID, a +phone-number JID, or a phone number (`+15551234567` or digits). Results and +event topics are always **canonical** (LID or group JID) once a LID is known. -The WhatsApp Web socket behind it is -[whatsmeow](https://github.com/tulir/whatsmeow); clients never talk to -whatsmeow directly. +> [JID](https://wiki.xmpp.org/web/JID_and_Contacts) or Jabber ID +> is the canonical identifier for a WhatsApp chat. LID is a +> logical identifier (like a username) that is stable across devices. -### Invocation +Chat events have no phone number — look up `By` (or a 1:1 `Topic`) with +`GetDirectoryAsync` when you need `Pn`. In 1:1 the sidecar ignores `by` on +reply / react / read; in groups every id in that call must share that author. + +## Native sidecar ```text whatsbox [--store ABSOLUTE_PATH] [--version] [--help] ``` -stdin / stdout is NDJSON JSON-RPC; stderr is logs. No default store. One -process, one store, one WhatsApp session. Full method table, event shapes, -and error tokens: [`docs/INBOX.md`](docs/INBOX.md). LID / QR / store layout: +stdin / stdout is NDJSON JSON-RPC; stderr is logs. LID / QR / store layout: [`docs/WHATSBOX.md`](docs/WHATSBOX.md). - +### v1 scope + +**Does:** pair via QR, connect / auto-reconnect / disconnect / logout, +directory populate + list/get + live `$directory`, subscribe by JID +(LID-first), live messages / receipts / in-chat `meta`, send `contents[]`, +reply, react, explicit mark-read. + +**Does not:** message history, search, backfill, export; stored bodies or +last-message previews; typing or “available” presence; edit or revoke; +pair-code or passkey pairing; channels, status, calls, blocklist or group +admin RPCs; MCP / sockets; multi-account in one process; topic wildcards; a +default store path. + ## Demo @@ -285,7 +385,7 @@ ndnx wd ``` `dnx` always goes through the SDK. `ndnx` starts the cached AOT binary -directly — no SDK after the first download. Pin a version (`wd@1.0.0`) +directly — no SDK needed at all. Pin a version (`wd@1.0.0`) to skip latest-version lookup. To install a `wd` command on PATH instead: @@ -304,19 +404,6 @@ reuse that store. -### v1 scope - -**Does:** pair via QR, connect / auto-reconnect / disconnect / logout, -directory populate + list/get + live `$directory`, subscribe by JID -(LID-first), live messages / receipts / in-chat `meta`, send `contents[]`, -reply, react, explicit mark-read. - -**Does not:** message history, search, backfill, export; stored bodies or -last-message previews; typing or “available” presence; edit or revoke; -pair-code or passkey pairing; channels, status, calls, blocklist or group -admin RPCs; MCP / sockets; multi-account in one process; topic wildcards; a -default store path. - --- # Sponsors diff --git a/src/Directory.props b/src/Directory.props index 8a24802..6f4e4be 100644 --- a/src/Directory.props +++ b/src/Directory.props @@ -1,6 +1,6 @@ - whatsbox + Inbox true \ No newline at end of file diff --git a/src/Inbox/Inbox.csproj b/src/Inbox/Inbox.csproj index 2f6cfaf..bd68bb9 100644 --- a/src/Inbox/Inbox.csproj +++ b/src/Inbox/Inbox.csproj @@ -4,7 +4,7 @@ Inbox Inbox Inbox - Managed reference client for an Inbox Protocol-implementation CLI (JSON-RPC 2.0, NDJSON over stdio), plus RID packing targets for native adapters. + Managed reference client for an Inbox Client Protocol (ICP) implementation CLI (JSON-RPC 2.0, NDJSON over stdio), plus RID packing targets for native adapters. true @@ -13,6 +13,7 @@ + diff --git a/src/Inbox/readme.md b/src/Inbox/readme.md index 624e9a5..ff1adce 100644 --- a/src/Inbox/readme.md +++ b/src/Inbox/readme.md @@ -1,14 +1,12 @@ The [`Inbox`](https://www.nuget.org/packages/Inbox) package is the managed -**Inbox Protocol** client (`InboxClient`) plus non-transitive MSBuild targets +**Inbox Client Protocol (ICP)** client (`InboxClient`) plus non-transitive MSBuild targets for adapters that ship a native sidecar. -```xml - -``` - Apps that only want WhatsApp should PackageReference `WhatsBox` instead — these targets are **not** transitive. + + ## Adapter packing `Inbox.targets` is imported only by a **direct** reference (nupkg `build/`, not diff --git a/src/Tests/InboxProtocolSpecTests.cs b/src/Tests/InboxProtocolSpecTests.cs index cb716ae..1c64e77 100644 --- a/src/Tests/InboxProtocolSpecTests.cs +++ b/src/Tests/InboxProtocolSpecTests.cs @@ -1,7 +1,7 @@ namespace Tests; /// -/// Structural checks on the shipped Inbox Protocol spec (docs/INBOX.md). +/// Structural checks on the shipped Inbox Client Protocol (ICP) spec (docs/INBOX.md). /// The artifact is the spec; this test drives that file on disk, not a reimplementation. /// public class InboxProtocolSpecTests @@ -30,7 +30,7 @@ static string LoadSpec() public void Spec_is_non_empty_prose() { Assert.True(spec.Length > 8_000, $"spec too short: {spec.Length}"); - Assert.Contains("# Inbox Protocol", spec, StringComparison.Ordinal); + Assert.Contains("# Inbox Client Protocol (ICP)", spec, StringComparison.Ordinal); Assert.Contains("JSON-RPC 2.0", spec, StringComparison.Ordinal); } diff --git a/src/Tests/ProjectIdentityDocsTests.cs b/src/Tests/ProjectIdentityDocsTests.cs new file mode 100644 index 0000000..e4f6121 --- /dev/null +++ b/src/Tests/ProjectIdentityDocsTests.cs @@ -0,0 +1,222 @@ +namespace Tests; + +/// +/// Structural checks on shipped current-facing docs after the project/repo rename to Inbox. +/// The artifacts are the markdown files; this test drives those files on disk. +/// +public class ProjectIdentityDocsTests +{ + static readonly string Repo = FindRepoRoot(); + + [Fact] + public void Root_readme_names_the_project_Inbox_and_states_the_adapter_split() + { + var text = File.ReadAllText(Path.Combine(Repo, "readme.md")); + var heading = Heading(text); + var inbox = Fragment(text, "inbox"); + var whatsbox = Fragment(text, "whatsbox"); + + Assert.Contains("Inbox", heading, StringComparison.Ordinal); + Assert.DoesNotContain("whatsbox", heading, StringComparison.OrdinalIgnoreCase); + + Assert.True( + text.IndexOf("", StringComparison.Ordinal) < + text.IndexOf("", StringComparison.Ordinal), + "root readme must present #inbox before #whatsbox"); + + Assert.Contains("Inbox Client Protocol (ICP)", inbox, StringComparison.Ordinal); + Assert.Contains("`InboxClient`", inbox, StringComparison.Ordinal); + Assert.DoesNotContain("whatsbox is **an Inbox Protocol", inbox, StringComparison.Ordinal); + + Assert.Contains("`whatsbox` adapter", whatsbox, StringComparison.Ordinal); + Assert.Contains("`WhatsBox`", whatsbox, StringComparison.Ordinal); + Assert.Contains("NuGet", whatsbox, StringComparison.Ordinal); + Assert.Contains("managed host", whatsbox, StringComparison.OrdinalIgnoreCase); + Assert.Contains("it is not the protocol itself", whatsbox, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Source_and_issue_links_are_devlooped_Inbox() + { + var agents = File.ReadAllText(Path.Combine(Repo, "AGENTS.md")); + Assert.Contains("`devlooped/Inbox`", agents, StringComparison.Ordinal); + Assert.DoesNotContain("devlooped/whatsbox", agents, StringComparison.Ordinal); + + var whatsBoxReadme = File.ReadAllText(Path.Combine(Repo, "src", "WhatsBox", "readme.md")); + Assert.Contains("https://github.com/devlooped/Inbox", whatsBoxReadme, StringComparison.Ordinal); + Assert.DoesNotContain("devlooped/whatsbox", whatsBoxReadme, StringComparison.Ordinal); + + var inboxReadme = File.ReadAllText(Path.Combine(Repo, "src", "Inbox", "readme.md")); + Assert.Contains("PackageReference `WhatsBox`", inboxReadme, StringComparison.Ordinal); + Assert.Contains("readme.md#inbox", inboxReadme, StringComparison.Ordinal); + Assert.DoesNotContain("readme.md#content", inboxReadme, StringComparison.Ordinal); + Assert.DoesNotContain("readme.md#whatsbox", inboxReadme, StringComparison.Ordinal); + + Assert.Contains("readme.md#whatsbox", whatsBoxReadme, StringComparison.Ordinal); + Assert.DoesNotContain("readme.md#content", whatsBoxReadme, StringComparison.Ordinal); + Assert.DoesNotContain("readme.md#inbox", whatsBoxReadme, StringComparison.Ordinal); + } + + [Fact] + public void Inbox_fragment_is_box_agnostic_protocol_and_client() + { + var inbox = Fragment(File.ReadAllText(Path.Combine(Repo, "readme.md")), "inbox"); + + Assert.Contains("Inbox Client Protocol (ICP)", inbox, StringComparison.Ordinal); + Assert.Contains("`InboxClient`", inbox, StringComparison.Ordinal); + Assert.Contains("`InboxEvent`", inbox, StringComparison.Ordinal); + Assert.Contains("ChatMessage", inbox, StringComparison.Ordinal); + Assert.Contains("contents", inbox, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Implementing a box", inbox, StringComparison.Ordinal); + Assert.Contains("new InboxClient(stdout, stdin)", inbox, StringComparison.Ordinal); + + Assert.DoesNotContain("QR pairing", inbox, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("whatsbox sidecar", inbox, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("whatsbox.exe", inbox, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("WhatsBox.win-x64", inbox, StringComparison.Ordinal); + Assert.DoesNotContain("runtime.json", inbox, StringComparison.Ordinal); + Assert.DoesNotContain("WhatsBoxClient", inbox, StringComparison.Ordinal); + Assert.DoesNotContain("whatsmeow", inbox, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void WhatsBox_fragment_is_the_WhatsApp_adapter() + { + var whatsbox = Fragment(File.ReadAllText(Path.Combine(Repo, "readme.md")), "whatsbox"); + + Assert.Contains("QR pairing", whatsbox, StringComparison.Ordinal); + Assert.Contains("store", whatsbox, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sidecar", whatsbox, StringComparison.OrdinalIgnoreCase); + Assert.Contains("whatsbox [--store ABSOLUTE_PATH]", whatsbox, StringComparison.Ordinal); + Assert.Contains("WhatsBoxClient", whatsbox, StringComparison.Ordinal); + Assert.Contains("https://www.nuget.org/packages/Inbox", whatsbox, StringComparison.Ordinal); + Assert.Contains("whatsmeow", whatsbox, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Remaining_content_markers_are_only_the_WhatsDemo_include() + { + var text = File.ReadAllText(Path.Combine(Repo, "readme.md")); + var whatsboxClose = text.LastIndexOf("", StringComparison.Ordinal); + Assert.True(whatsboxClose > 0); + + var i = 0; + var hits = 0; + while (true) + { + var at = text.IndexOf("", i, StringComparison.Ordinal); + if (at < 0) + break; + hits++; + Assert.True(at > whatsboxClose, " before #whatsbox close at " + at); + i = at + 1; + } + + Assert.Equal(2, hits); + Assert.Contains("src/WhatsDemo/readme.md#content", text, StringComparison.Ordinal); + } + + [Fact] + public void WhatsBox_profile_is_the_adapter_not_this_repository() + { + var text = File.ReadAllText(Path.Combine(Repo, "docs", "WHATSBOX.md")); + var firstRule = text.IndexOf("---", StringComparison.Ordinal); + Assert.True(firstRule > 0, "WHATSBOX.md intro missing horizontal rule"); + var intro = text[..firstRule]; + + Assert.Contains("WhatsApp adapter", intro, StringComparison.Ordinal); + Assert.Contains("Inbox Client Protocol (ICP)", intro, StringComparison.Ordinal); + Assert.Contains("`whatsbox`", intro, StringComparison.Ordinal); + Assert.Contains("`WhatsBox` NuGet", intro, StringComparison.Ordinal); + Assert.DoesNotContain("greenfield repo", intro, StringComparison.Ordinal); + Assert.DoesNotContain("this repository is whatsbox", intro, StringComparison.OrdinalIgnoreCase); + + Assert.Contains("whatsbox [--store ABSOLUTE_PATH]", text, StringComparison.Ordinal); + } + + [Fact] + public void Osmf_software_name_is_Inbox() + { + var text = File.ReadAllText(Path.Combine(Repo, "osmfeula.txt")); + Assert.Contains("Inbox (\"Software\")", text, StringComparison.Ordinal); + Assert.DoesNotContain("whatsbox (\"Software\")", text, StringComparison.Ordinal); + } + + [Fact] + public void Current_facing_docs_have_no_devlooped_whatsbox_links() + { + var hits = new List(); + foreach (var file in CurrentFacingDocs()) + { + var n = 0; + foreach (var line in File.ReadAllLines(file)) + { + n++; + if (line.Contains("devlooped/whatsbox", StringComparison.Ordinal)) + hits.Add($"{Path.GetRelativePath(Repo, file)}:{n}:{line.Trim()}"); + } + } + + Assert.True(hits.Count == 0, string.Join(Environment.NewLine, hits)); + } + + static string Heading(string readme) + { + var end = readme.IndexOf("============", StringComparison.Ordinal); + Assert.True(end > 0, "readme.md missing underline heading"); + return readme[..end]; + } + + static string Fragment(string readme, string name) + { + var marker = ""; + var start = readme.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0, "readme.md missing opening " + marker); + start += marker.Length; + var end = readme.IndexOf(marker, start, StringComparison.Ordinal); + Assert.True(end > start, "readme.md missing closing " + marker); + return readme[start..end]; + } + + static IEnumerable CurrentFacingDocs() + { + yield return Path.Combine(Repo, "readme.md"); + yield return Path.Combine(Repo, "AGENTS.md"); + yield return Path.Combine(Repo, "osmfeula.txt"); + yield return Path.Combine(Repo, "changelog.md"); + + foreach (var file in Directory.EnumerateFiles(Path.Combine(Repo, "src"), "readme.md", SearchOption.AllDirectories)) + { + if (IsBuildOutput(file)) + continue; + yield return file; + } + + foreach (var file in Directory.EnumerateFiles(Path.Combine(Repo, "docs"), "*.md", SearchOption.AllDirectories)) + { + if (string.Equals(Path.GetFileName(file), "RFC-1.md", StringComparison.OrdinalIgnoreCase)) + continue; + yield return file; + } + } + + static bool IsBuildOutput(string path) + { + var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return parts.Any(p => p.Equals("bin", StringComparison.OrdinalIgnoreCase) + || p.Equals("obj", StringComparison.OrdinalIgnoreCase)); + } + + static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "WhatsBox.slnx"))) + return dir.FullName; + dir = dir.Parent; + } + + throw new InvalidOperationException("Could not find WhatsBox.slnx from " + AppContext.BaseDirectory); + } +} diff --git a/src/WhatsBox/WhatsBox.csproj b/src/WhatsBox/WhatsBox.csproj index 30b1692..f5dc1da 100644 --- a/src/WhatsBox/WhatsBox.csproj +++ b/src/WhatsBox/WhatsBox.csproj @@ -4,7 +4,7 @@ WhatsBox WhatsBox WhatsBox - WhatsApp Inbox Protocol host: PackageReference WhatsBox and publish for your RID to get the native whatsbox sidecar plus the managed Inbox client. + WhatsApp Inbox Client Protocol (ICP) host: PackageReference WhatsBox and publish for your RID to get the native whatsbox sidecar plus the managed Inbox client. true win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64 true diff --git a/src/WhatsBox/readme.md b/src/WhatsBox/readme.md index 9b523d8..bcd5ded 100644 --- a/src/WhatsBox/readme.md +++ b/src/WhatsBox/readme.md @@ -1,8 +1,8 @@ [![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) [![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) -[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/whatsbox) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/Inbox) - +