From d8c823c813c77821839cb1fa88cec5f8372353de Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sun, 23 Aug 2026 22:55:35 -0300 Subject: [PATCH 1/2] Advertise claimed me, membership verbs, and typed protocol capabilities. ICP now describes how session me is bound (issued vs claimed), a membership total order (none/join/create), and directory.find/join/leave/create. The C# client and WhatsBox sidecar follow that surface. Closed unions for session status, identity, directory kind, and capabilities are enums; product, profile, and protocol version stay strings. Also document PubSubBox and rename the solution to Inbox.slnx. --- CONTEXT.md | 57 +++ WhatsBox.slnx => Inbox.slnx | 0 docs/INBOX.md | 333 ++++++++++++++---- docs/PUBSUBBOX.md | 226 ++++++++++++ docs/WHATSBOX.md | 2 + docs/adr/0001-claimed-me-is-pair-input.md | 5 + docs/adr/0002-membership-is-a-total-order.md | 5 + ...-pubsub-blobs-are-url-in-content-binary.md | 5 + docs/adr/0004-omit-profile-means-chat.md | 3 + readme.md | 5 +- src/Inbox/Capabilities.cs | 140 ++++++++ src/Inbox/InboxClient.cs | 62 +++- src/Inbox/InboxJsonContext.cs | 16 +- src/Inbox/JsonRpcContext.cs | 6 +- src/Inbox/Requests.cs | 37 +- src/Inbox/Results.cs | 95 +++-- src/Tests/InboxClientTests.cs | 78 +++- src/Tests/InboxJsonContextTests.cs | 51 ++- src/Tests/InboxProtocolSpecTests.cs | 22 +- src/Tests/ProjectIdentityDocsTests.cs | 17 + src/WhatsBox.Native/app/daemon.go | 21 +- src/WhatsBox.Native/app/protocol_test.go | 26 +- src/WhatsBox.Native/rpc/rpc.go | 6 + src/WhatsDemo.Tests/AtMentionsTests.cs | 2 +- src/WhatsDemo.Tests/DirectoryBookTests.cs | 8 +- src/WhatsDemo.Tests/DirectorySyncTests.cs | 12 +- src/WhatsDemo.Tests/JsonPanelTests.cs | 4 +- src/WhatsDemo.Tests/SlashCommandsTests.cs | 6 +- src/WhatsDemo.Tests/TopicResolverTests.cs | 14 +- src/WhatsDemo/Program.cs | 6 +- 30 files changed, 1110 insertions(+), 160 deletions(-) create mode 100644 CONTEXT.md rename WhatsBox.slnx => Inbox.slnx (100%) create mode 100644 docs/PUBSUBBOX.md create mode 100644 docs/adr/0001-claimed-me-is-pair-input.md create mode 100644 docs/adr/0002-membership-is-a-total-order.md create mode 100644 docs/adr/0003-pubsub-blobs-are-url-in-content-binary.md create mode 100644 docs/adr/0004-omit-profile-means-chat.md create mode 100644 src/Inbox/Capabilities.cs diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..7bf13dd --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,57 @@ +# Inbox + +A same-machine companion bus: one **box** owns one messaging-product session and exposes it as **ICP**. This glossary is the language for that bus, not for any particular consumer (REPL, agent, appliance). + +## Language + +**ICP**: +Inbox Client Protocol: JSON-RPC 2.0 pub/sub on stdio (NDJSON). The client-facing contract every box speaks. +_Avoid_: IPC, MCP, ACP, A2A, the native product API. + +**Box**: +One process, one store, one product session. Speaks only ICP on stdin/stdout. +_Avoid_: daemon-as-product, gateway, service, adapter-as-the-protocol. + +**Adapter**: +A box for a specific product (`whatsbox`, `pubsubbox`). The adapter is not ICP. `pubsubbox` omits `profile`; a later hub surface would set `profile: "hub"` (`profile` omitted ≡ chat). +_Avoid_: calling the managed NuGet host “the protocol.” + +**Client**: +Anything that speaks ICP to a box (`InboxClient`, a REPL, an appliance). Product HTTP, Bonjour, and operator chrome belong to the client, not the box. +_Avoid_: treating an appliance image as this repository’s product. + +**Me**: +The paired product identity string on the session (`SessionSnapshot.me`, event `by: "me"`). Opaque. Chosen according to **me binding**, never `deviceName`, never the operator’s Entra UPN. +_Avoid_: self, userId-as-a-protocol-type, handle, deviceName. + +**Me binding**: +How `me` is bound at pair time. **Issued**: the product supplies `me` after auth (WhatsApp LID, Discord bot snowflake). Passing `me` on initialize/pair → `invalid_params`. **Claimed**: the client supplies `me` as input on `initialize` and/or `session.pair`. Omit when claimed → error token `me_required` (not a `$session` kind). `me` on `initialize` (even with `connect: false`) is remembered for the following pair; both set and different → `invalid_params`. +_Avoid_: asserted, impersonating, self-registering, externally registered (those phrases collapse into issued vs claimed). + +**Roster**: +The directory’s set of chats this `me` already belongs to. `directory.list` / `directory.get` read the roster. Create and join write it; find does not. +_Avoid_: address book as a second store, live product search. + +**Find**: +Live product lookup of chats this `me` could join. Same params as `directory.list` (`query?`, `kind?`, `limit?`, `cursor?`). Returns rows (canonical `topic` + labels). Does not write the roster. When `membership` is `join` or `create`, `subscribe` of a topic with no roster row is `not_found`. +_Avoid_: overloading `directory.list` query, search-as-join. + +**Create**: +`directory.create` `{name, topic?}` — always a group. Product assigns the topic if omitted. Writes the roster; does not subscribe. Result is `{topic}`. Same `me` + same topic → no-op. Another occupant → `topic_taken`. `$` / garbage → `invalid_topic`. +_Avoid_: 1:1 create, participant lists on create, join-or-create. + +**Join**: +`directory.join` `{id}` — `id` is a canonical topic from find, create, or the roster. Names are `invalid_topic`. Already a member → no-op `{topic}`. A `kind: "user"` find row opens (or reuses) a 1:1; the result topic may `remap`. Writes the roster; does not subscribe. Leave of a non-member → `not_found`. +_Avoid_: alias join, send-to-create-DM. + +**Membership**: +Product-side add/remove of this `me` from a chat. Not subscribe. `capabilities.membership` is `"none"` | `"join"` | `"create"` (total order: `create` ⊃ `join` ⊃ roster). `none`: find/join/leave/create → `unsupported`. `join`: find/join/leave. `create`: those plus `directory.create`. Join writes the roster and does not subscribe. Leave ends product membership, `$directory` `remove`s the row, and drops a held subscription. `unsubscribe` never leaves. Find/join/leave/create are **online-only**. +_Avoid_: using subscribe to join, per-product verbs (`rooms.join`), a second boolean for create. + +**Subscribe**: +Client intent to receive live `event`s on a canonical topic. Newly subscribed topics get no replay. Unrelated to product membership. +_Avoid_: watch, join, follow. + +**Topic**: +Opaque chat id (or `$session` / `$directory`). The client copies it; it does not parse product suffixes. +_Avoid_: JID, snowflake, roomId as client-visible types. diff --git a/WhatsBox.slnx b/Inbox.slnx similarity index 100% rename from WhatsBox.slnx rename to Inbox.slnx diff --git a/docs/INBOX.md b/docs/INBOX.md index 6831380..cb4694b 100644 --- a/docs/INBOX.md +++ b/docs/INBOX.md @@ -16,7 +16,7 @@ A client implements the methods, events, store/files rules, and error tokens in There are **no** per-product method names (`discord.send`, `slack.postMessage`, `graph.chats`, …). A WhatsBox `0.1` client codec MUST be able to speak this envelope without a second framing dialect: extra JSON fields are additive and MUST be ignored by clients that do not understand them. -Suggested binaries (not normative): `whatsbox`, `discordbox`, `slackbox`, `teamsbox`, `telegrambox`, `matrixbox`. +Suggested binaries (not normative): `whatsbox`, `pubsubbox`, `discordbox`, `slackbox`, `teamsbox`, `telegrambox`, `matrixbox`. --- @@ -41,6 +41,7 @@ Agents and local apps that can spawn a process, speak newline-delimited JSON-RPC - Authenticate via the product’s official path, surfaced as `$session` events a single client can render. - Connect, auto-reconnect, disconnect, logout. - Directory populate + list/get + live `$directory` updates. +- When advertised (`capabilities.membership`), find / join / leave / create chats — product membership, **not** subscribe. - Subscribe/unsubscribe to chats by **canonical topic** only. - Receive live messages and in-chat `meta` on the chat topic; receipts (`ack`) when the product provides them. - Send `contents[]` (text, file if `files` is set, reply, react) — as far as advertised capabilities allow. @@ -48,7 +49,7 @@ Agents and local apps that can spawn a process, speak newline-delimited JSON-RPC ### 1.4 v1 does not -- Message history, search, backfill, export, FTS. +- Message history, message search, backfill, export, FTS (`directory.find` is live product lookup of chats, not a transcript search). - Store message bodies or last-message previews. - Typing indicators or “available” presence (quiet companion). - Edit, revoke (“delete for everyone”). @@ -57,7 +58,7 @@ Agents and local apps that can spawn a process, speak newline-delimited JSON-RPC - Named multi-account in one process. - Topic wildcards (`#`, `+`, `$all`). - Default store path. -- Per-product RPC methods to paper over gaps. Missing features are **capabilities** (and documented degraded behavior or a stable error token), not extra verbs. +- Per-product RPC methods to paper over gaps. Missing features are **capabilities** (and documented degraded behavior or a stable error token), not extra verbs. `directory.find` / `join` / `leave` / `create` exist on the method table; products that cannot honor them advertise `membership: "none"` and return `unsupported`. --- @@ -207,7 +208,9 @@ WhatsApp (reference): 1:1 is a LID JID (`123@lid`); groups are `120363…@g.us`; ### 4.2 Input acceptance -`subscribe` / `unsubscribe` (and `initialize.subscribe`) accept **canonical topics only**: a directory row’s `topic`, or `$directory`. Names, handles, phones, Slack `#channel` aliases, Discord snowflake-as-mention, Matrix aliases (`#room:server`) are **not** resolved here — the client looks those up with `directory.list` and passes the row’s `topic`. Unknown / unresolvable entries fail the **whole** call (`invalid_topic`; no partial apply). +`subscribe` / `unsubscribe` (and `initialize.subscribe`) accept **canonical topics only**: a directory row’s `topic`, or `$directory`. Names, handles, phones, Slack `#channel` aliases, Discord snowflake-as-mention, Matrix aliases (`#room:server`) are **not** resolved here — the client looks those up with `directory.list` (roster) or `directory.find` (live product lookup when `membership` is `join` or `create`) and passes the row’s `topic`. Unknown / unresolvable entries fail the **whole** call (`invalid_topic`; no partial apply). + +When `capabilities.membership` is `"join"` or `"create"`, subscribe of a chat topic that is **not** on the roster is `not_found` (whole call; no partial apply). `membership: "none"` keeps today’s rule: a canonical topic may be subscribed without a roster row (WhatsApp JID). `$directory` is never a membership check. These fields MAY accept a product-defined **alias** that the daemon normalizes to a canonical topic (WhatsApp: LID, PN JID, or phone; others: see profiles): @@ -245,7 +248,7 @@ Products that never remap MAY omit `remap`. Clients MUST still handle it. - **Inbound events:** `by` is `"me"` or an opaque author id. There is no separate `self` field. - **Status snapshot:** the paired id is `me` only. Do not also emit `self`. -v1 stores **no** message bodies. The client that displayed a line is the only place that has quote text; see §6.10 `reply.text`. +v1 stores **no** message bodies. The client that displayed a line is the only place that has quote text; see §6.14 `reply.text`. ### 4.5 `context` (optional grouping key) @@ -261,7 +264,20 @@ v1 stores **no** message bodies. The client that displayed a line is the only pl One Reply action. There is **no** “start a new context” verb in v1: Slack cannot fork a thread from a child. Replying to a child **copies** `e.context` (the root), which is how the client avoids using the child id as `thread_ts` without a daemon history cache. -Products MAY emit `context` even when `capabilities.reply` is `"quote"` (Matrix `m.thread`, Teams channel reply lists). Grouping is data-driven. The `"context"` value only tells the UI that the **first** reply on a message with no `context` yet will **create** a group, not a quote. A stricter always-send variant (zero send-path branch) is in §21.7. +Products MAY emit `context` even when `capabilities.reply` is `"quote"` (Matrix `m.thread`, Teams channel reply lists). Grouping is data-driven. The `"context"` value only tells the UI that the **first** reply on a message with no `context` yet will **create** a group, not a quote. A stricter always-send variant (zero send-path branch) is in §22.7. + +### 4.6 Me binding + +`SessionSnapshot.me` / `$session` `paired.me` is the paired **product** identity. How it is bound is `capabilities.me`: + +| Value | Who chooses `me` | `me` on `initialize` / `session.pair` | +|---|---|---| +| `"issued"` | The product, after auth (WhatsApp LID, Discord bot snowflake, Graph oid) | **Must omit.** Present → `invalid_params`. | +| `"claimed"` | The client | **Required** on the pairing call. Omit → error token `me_required` (not a `$session` kind). | + +`me` on `initialize` is remembered even when `connect` is false and applied to the following `session.pair` / `session.connect`. If initialize and pair both set `me` and they differ → `invalid_params`. Claimed `me` is pair **input**, not pair progress: there is no `me_required` event and no file-watch inject. Device-code / QR wait still happens **inside** `pair({me})` after the name is known. + +`deviceName` stays the companion label. It is not `me`. --- @@ -273,9 +289,9 @@ Products MAY emit `context` even when `capabilities.reply` is `"quote"` (Matrix | Field | Type | Description | |---|---|---| -| `product` | string | `whatsapp` \| `discord` \| `slack` \| `teams` \| `telegram` \| `matrix` | +| `product` | string | `whatsapp` \| `webpubsub` \| `discord` \| `slack` \| `teams` \| `telegram` \| `matrix` | | `identity` | string | `user` \| `bot` | -| `profile` | string? | Product sub-profile when one binary could be either (Telegram: `bot` \| `user`). Omit when identical to `identity`. | +| `profile` | string? | Product sub-profile when one binary could be either (Telegram: `bot` \| `user`). Omit when identical to `identity`, **or** when the product’s default surface is implied (Web PubSub: omit ≡ Chat hub; a later `profile: "hub"` would be the base hub). | | `capabilities` | object | See §5.2. | A WhatsBox `0.1` codec that does not bind these fields still works: they are additive. A unifying client MUST read them to disable UI / skip RPCs that the product cannot honor. @@ -287,9 +303,11 @@ Every key below is required on the wire so a client never has to guess. | Key | Type | Meaning | |---|---|---| | `auth` | string[] | How `session.pair` / implicit pair authenticates. Subset of `qr`, `oauth`, `device_code`, `token`. | +| `me` | `"issued"` \| `"claimed"` | How session `me` is bound. See §4.6. | +| `membership` | `"none"` \| `"join"` \| `"create"` | Product-side add/remove of this `me` from a chat. Total order: `create` ⊃ `join` ⊃ roster. See below. | | `reply` | `"quote"` \| `"context"` \| `"none"` | String enum. JSON `true` / `false` are **not** members and **not** aliases of `"quote"` / `"none"`. `"quote"` = in-chat quote (WhatsApp / Discord / Telegram / Matrix). `"context"` = grouping key (Slack threads): send still uses `reply` plus optional `context` (§4.5); not a quote bubble. `"none"` = `messages.send` with `reply` → `unsupported`. | | `react` | boolean | Two-state. A `{type:"reaction"}` content part on `messages.send` works. Off is JSON `false`, not `"none"`. | -| `read` | `"message"` \| `"cursor"` \| `"conversation"` \| `"none"` | String enum. JSON `true` / `false` are **not** members and **not** aliases of `"none"`. See §6.11. `"none"` = `unsupported`. | +| `read` | `"message"` \| `"cursor"` \| `"conversation"` \| `"none"` | String enum. JSON `true` / `false` are **not** members and **not** aliases of `"none"`. See §6.15. `"none"` = `unsupported`. | | `ack` | boolean | Chat `kind: ack` events (`delivered` / `read` / `played`) will be emitted when the product has them. | | `files` | boolean | Product can move blobs through `initialize.files`. Still requires the client to pass `files`. | | `attachments` | `"none"` \| `"single"` \| `"many"` | How many **blob parts** (`image` `video` `audio` `document` `sticker`) one `kind: message` may carry. String enum; JSON `false` is **not** `"none"`. | @@ -308,6 +326,18 @@ There is **no** `e2ee` capability. WhatsApp Signal is inside the library and is `files` is blob plumbing (media + icons). `attachments` is cardinality of media **parts**. Missing `initialize.files` is still `files_required` even when `attachments` is `"many"`. Profiles with `attachments: "none"` set `files: false`. +`me` values: see §4.6. + +`membership` values: + +| Value | RPCs | +|---|---| +| `"none"` | `directory.find` / `join` / `leave` / `create` → `unsupported` (`capability: "membership"`). Join happens in the product’s own UI (WhatsApp phone, Discord invite). | +| `"join"` | `directory.find`, `directory.join`, `directory.leave`. `directory.create` → `unsupported`. | +| `"create"` | `"join"` plus `directory.create`. | + +Find / join / leave / create are **online-only** (`disconnected` when not `online`). Roster `list` / `get` remain valid offline. Join writes the roster and does **not** subscribe. Leave ends product membership, `$directory` `remove`s the row, and drops a held subscription. `unsubscribe` never leaves the product. + `read` values: | Value | `messages.read` behavior | @@ -325,9 +355,9 @@ There is **no** `e2ee` capability. WhatsApp Signal is inside the library and is | `"context"` | RPC **succeeds**. The daemon posts into the grouping key `context` if provided, else `reply.id` (start a group rooted at that message). `reply.text` MAY be ignored. Not a quote bubble — the client SHOULD group by `context` and MUST NOT draw a WhatsApp-style citation. Documented lossy mapping vs quotes, not `unsupported`. | | `"none"` | `messages.send` with `reply` → `unsupported` with `error.data.capability = "reply"`. | -A daemon MUST advertise only the members in the tables above. In particular it MUST NOT put JSON `true` or `false` on `reply`, `read`, or `attachments` — those are not synonyms of `"none"`. Two-state keys (`react`, `ack`, `files`) stay JSON booleans. +A daemon MUST advertise only the members in the tables above. In particular it MUST NOT put JSON `true` or `false` on `reply`, `read`, `attachments`, `me`, or `membership` — those are not synonyms of `"none"`. Two-state keys (`react`, `ack`, `files`) stay JSON booleans. -A client that receives a boolean or unknown string on `reply` / `read` / `attachments` MAY hide the action (same UI as `"none"`) so a non-conformant daemon does not crash it. That is fail-closed parsing, not a second legal value: the client MUST NOT echo the boolean and MUST NOT document it as valid. +A client that receives a boolean or unknown string on `reply` / `read` / `attachments` / `me` / `membership` MAY hide the action (same UI as `"none"` / issued) so a non-conformant daemon does not crash it. That is fail-closed parsing, not a second legal value: the client MUST NOT echo the boolean and MUST NOT document it as valid. When the advertised capability does not allow a send/read field: @@ -338,13 +368,15 @@ When the advertised capability does not allow a send/read field: | `read: "none"` | `messages.read` | `unsupported` (`capability: "read"`). | | `attachments: "none"` | any blob part | `unsupported` (`capability: "attachments"`). | | `attachments: "single"` | two or more blob parts | `unsupported` (`capability: "attachments"`). | +| `membership: "none"` | `directory.find` / `join` / `leave` / `create` | `unsupported` (`capability: "membership"`). | +| `membership: "join"` | `directory.create` | `unsupported` (`capability: "membership"`). | Do not silently drop a content part the client set. ### 5.3 Example status snapshot ```json -{"version":"0.1","status":"online","me":"123456789012345678","product":"discord","identity":"bot","topics":["$session","$directory"],"capabilities":{"auth":["token"],"reply":"quote","react":true,"read":"none","ack":false,"files":true,"attachments":"many"}} +{"version":"0.1","status":"online","me":"123456789012345678","product":"discord","identity":"bot","topics":["$session","$directory"],"capabilities":{"auth":["token"],"me":"issued","membership":"none","reply":"quote","react":true,"read":"none","ack":false,"files":true,"attachments":"many"}} ``` --- @@ -365,7 +397,8 @@ Only these methods exist in v1. This is the WhatsBox v1 noun set. Products MUST "subscribe": ["$directory"], "verbosity": "info", "connect": true, - "deviceName": "box on DESKTOP-ADA" + "deviceName": "box on DESKTOP-ADA", + "me": "alice" } ``` @@ -378,6 +411,7 @@ Only these methods exist in v1. This is the WhatsBox v1 noun set. Products MUST | `verbosity` | no | stderr level. | | `connect` | no | If `true`, implicit `session.connect` after subscriptions are installed. Default `false`. | | `deviceName` | no | Display name for this companion (WhatsApp linked-device name; Matrix `device_display_name`; others MAY ignore). Omitted or blank → `{bin} on {hostname}`. | +| `me` | if `capabilities.me` is `"claimed"` and this call (or a later pair) will pair | Claimed product identity. See §4.6. Issued products MUST omit. | **Store resolution** @@ -399,10 +433,10 @@ Apply `subscribe` (plus implicit `$session`) **before** any event is eligible fo **Result** (already paired, `connect` omitted) ```json -{"version":"0.1","status":"offline","me":"111@lid","product":"whatsapp","identity":"user","topics":["$session","$directory"],"capabilities":{"auth":["qr"],"reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"}} +{"version":"0.1","status":"offline","me":"111@lid","product":"whatsapp","identity":"user","topics":["$session","$directory"],"capabilities":{"auth":["qr"],"me":"issued","membership":"none","reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"}} ``` -If never paired and `connect` is not true: `"status":"new"`, `me` omitted. `product` / `capabilities` are still present so the client can render the right auth UI before connecting. +If never paired and `connect` is not true: `"status":"new"`, snapshot `me` omitted (claimed `params.me` is stored for the later pair, not echoed as paired identity). `product` / `capabilities` are still present so the client can render the right auth UI and, when `capabilities.me` is `"claimed"`, collect `me` before `connect` / `pair`. ### 6.2 `session.connect` @@ -422,10 +456,15 @@ Bring up the product connection. `initialize` with `connect: true` is this metho Start the product’s auth flow and wait until linked (or fail). +```json +{"me": "alice"} +``` + +- `me`: see §4.6. Claimed: required here unless `initialize` already supplied it. Issued: omit. - Already paired (`offline` or `online`): **no-op**, return current status. To re-pair the client MUST `session.logout` first. - Auth progress is `$session` events (see §7.1). Client renders the latest. No per-event reply. - Success: `{kind:"paired"}` then the session is linked. Pairing **ends connected** when invoked standalone; when invoked from `connect`, `connect` finishes `online`. -- Failure: `{kind:"pair_error", message}` and the RPC fails with `pair_error`. +- Failure: `{kind:"pair_error", message}` and the RPC fails with `pair_error`. Claimed pair without `me` fails immediately with `me_required` (no `$session` event, no device-code wait). ### 6.4 `session.disconnect` @@ -444,7 +483,7 @@ See §2.4. Result is `session.status` with `status: "new"`. "product": "whatsapp", "identity": "user", "topics": ["$session", "$directory"], - "capabilities": {"auth":["qr"],"reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"} + "capabilities": {"auth":["qr"],"me":"issued","membership":"none","reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"} } ``` @@ -465,11 +504,12 @@ See §2.4. Result is `session.status` with `status: "new"`. **Result:** `{topics:[…]}` canonical topics actually applied. - Display name / roster are **not** on this result. Call `directory.get` if needed. -- Names, handles, phones, aliases are `invalid_topic`. Resolve with `directory.list`. +- Names, handles, phones, aliases are `invalid_topic`. Resolve with `directory.list` (or `directory.find` when membership allows). - Unknown entries fail the whole call (no partial apply). +- When `membership` is `"join"` or `"create"`, a chat topic with no roster row is `not_found` (whole call). - `$session` cannot be unsubscribed. - Subscribing an already-subscribed topic is a no-op. -- Newly subscribed chats get **no replay**; next live event onward. +- Newly subscribed chats get **no replay**; next live event onward. `directory.join` does **not** subscribe. ### 6.8 `directory.list` @@ -486,7 +526,7 @@ See §2.4. Result is `session.status` with `status: "new"`. **Result:** `{items: [DirectoryRow], cursor?}`. No `cursor` (or empty) means last page. -There is **no** sort-by-last-message (we do not store it). Order: implementation-defined but stable (recommend name, then topic). Directory search stays on this RPC — never on `subscribe`. +There is **no** sort-by-last-message (we do not store it). Order: implementation-defined but stable (recommend name, then topic). This RPC searches the **roster**. Live product lookup of chats this `me` could join is `directory.find`. Never search on `subscribe`. ### 6.9 `directory.get` @@ -511,7 +551,58 @@ There is **no** sort-by-last-message (we do not store it). Order: implementation `list` / `$directory` upsert never carry `icon`. Missing entity → `not_found`. -### 6.10 `messages.send` +### 6.10 `directory.find` + +Live product lookup of chats this `me` **could** join. Does **not** write the roster. `membership: "none"` → `unsupported`. Not `online` → `disconnected`. + +Params: same object as `directory.list` (`query?`, `kind?`, `limit?`, `cursor?`). Empty `query` = first page of what the product will show. + +**Result:** `{items: [DirectoryRow], cursor?}` — same page shape as list. Rows include canonical `topic` and `kind`. A `kind: "user"` row is a person this `me` may open a 1:1 with; it is **not** yet a roster chat. Subscribe of a find hit that was not joined → `not_found`. + +### 6.11 `directory.join` + +```json +{"id":"opaque-canonical-topic"} +``` + +Add this `me` to an existing chat (or open a 1:1 from a `kind: "user"` find topic). Canonical topic only (from find, create, or the roster). Names / aliases → `invalid_topic`. + +- `membership: "none"` → `unsupported`. Not `online` → `disconnected`. +- Already a member → no-op `{topic}`. +- A `kind: "user"` topic MAY remap to a group/1:1 chat topic: upsert the new row, `$session` `{kind:"remap", from, to}`, result `{topic}` is the **canonical chat** after remap. Further send/subscribe use `to`. +- Writes the roster. Does **not** subscribe. +- **Result:** `{topic}`. + +### 6.12 `directory.leave` + +```json +{"id":"opaque-canonical-topic"} +``` + +End product membership. Canonical topic only. + +- `membership: "none"` → `unsupported`. Not `online` → `disconnected`. +- Not a member → `not_found`. +- On success: product leave, `$directory` `remove`, drop a held subscription if any. `unsubscribe` never does this. +- **Result:** `{topic}` of the chat left (canonical). + +### 6.13 `directory.create` + +```json +{"name":"Project Falcon","topic":"falcon"} +``` + +Create a **group**. `name` required. `topic` optional (claimed id); omit → the product assigns one. + +- `membership` other than `"create"` → `unsupported`. Not `online` → `disconnected`. +- Always `kind: "group"`. 1:1 is `directory.join` of a user topic from find, not create. +- `$` prefix / garbage `topic` → `invalid_topic`. +- Same `me` already has this topic → no-op `{topic}`. +- Another occupant holds that topic → `topic_taken`. +- Writes the roster. Does **not** subscribe. No participant list (invitees join themselves). +- **Result:** `{topic}`. + +### 6.14 `messages.send` ```json { @@ -561,7 +652,7 @@ Rules: Daemon **never** looks up quote bodies. `reply.text` is optional but required for a visible quote on clients that do not have history (every Box consumer). -### 6.11 `messages.read` +### 6.15 `messages.read` ```json {"to":"opaque-group","ids":["3EB0…","3EB1…"],"by":"999@lid"} @@ -821,13 +912,15 @@ JSON-RPC application errors use codes in the `-32000`…`-32099` range. `error.m | `unsupported_version` | -32006 | `version` not `"0.1"` | | `not_paired` | -32007 | Action needs keys/tokens | | `pair_error` | -32008 | Auth failed | -| `not_found` | -32009 | `directory.get` / unknown topic resolution | -| `invalid_topic` | -32010 | `$` reserved, bad topic, alias on subscribe, unsubscribe `$session` | +| `not_found` | -32009 | `directory.get` / unknown topic resolution / subscribe of a non-roster chat when `membership` is `join` or `create` / `directory.leave` when not a member | +| `invalid_topic` | -32010 | `$` reserved, bad topic, alias on subscribe or `directory.join` / `leave`, unsubscribe `$session` | | `files_required` | -32011 | Blob op without `files` | | `path_escape` | -32012 | `path` outside `files` | -| `invalid_params` | -32013 | Missing `by` on read / reply / react, etc. | -| `disconnected` | -32014 | Needs `online` (send, read, live resolve) | -| `unsupported` | -32015 | Advertised capability does not allow the action (`react` `false`, `reply`/`read`/`attachments` `"none"`, or `attachments: "single"` with two blob parts), **or** the topic cannot honor the RPC (ciphertext-only room). When it is a capability gap, `error.data.capability` names the key (`read`, `reply`, `react`, `attachments`, …). | +| `invalid_params` | -32013 | Missing `by` on read / reply / react, `me` on an issued product, etc. | +| `disconnected` | -32014 | Needs `online` (send, read, find/join/leave/create, live resolve) | +| `unsupported` | -32015 | Advertised capability does not allow the action (`react` `false`, `reply`/`read`/`attachments` `"none"`, `membership: "none"` on find/join/leave/create, `membership: "join"` on create, or `attachments: "single"` with two blob parts), **or** the topic cannot honor the RPC (ciphertext-only room). When it is a capability gap, `error.data.capability` names the key (`read`, `reply`, `react`, `attachments`, `membership`, …). | +| `me_required` | -32016 | `capabilities.me` is `"claimed"` and `me` was omitted on pair / connecting initialize | +| `topic_taken` | -32017 | `directory.create` `{topic}` is held by another occupant | Standard JSON-RPC: `parse_error` -32700, `invalid_request` -32600, `method_not_found` -32601. @@ -842,8 +935,8 @@ A single client (the WhatsBox managed codec, a REPL, an agent) implements the me | Concern | Client does | Client does not | |---|---|---| | Framing | NDJSON JSON-RPC 2.0, named params, `event` notifications | Product REST, Gateway opcodes, Graph payloads, MCP | -| Find a chat | `directory.list` then `subscribe` with `row.topic` | Subscribe by name / phone / `#channel` | -| Auth UX | Render `$session` `qr` / `oauth` / `device_code` / `token_required` | Product-specific pair RPCs | +| Find a chat | `directory.list` (roster) or `directory.find` then `directory.join` (when advertised) then `subscribe` with `row.topic` | Subscribe by name / phone / `#channel`; join via `subscribe` | +| Auth UX | Render `$session` `qr` / `oauth` / `device_code` / `token_required`; if `capabilities.me` is `"claimed"`, pass `me` on pair | Product-specific pair RPCs; a `me_required` **event** | | Capabilities | Read `product` + `capabilities`; hide or skip impossible actions | Call `discord.read` because WhatsApp has blue ticks | | `by` / topics / `context` | Copy opaque strings | Parse `@lid`, snowflakes, `thread_ts`, `!room:server` | | Grouping | If any event on the topic has `context`, bucket by it; else flat | Per-product thread APIs; `context` as a subscribe topic | @@ -863,9 +956,9 @@ If a section of a profile required per-product client method names, that profile | Method | In | Out | |---|---|---| -| `initialize` | `version`, `store?`, `files?`, `subscribe?`, `verbosity?`, `connect?`, `deviceName?` | status snapshot (`connect:true` ⇒ `session.connect`) including `product` + `capabilities` | +| `initialize` | `version`, `store?`, `files?`, `subscribe?`, `verbosity?`, `connect?`, `deviceName?`, `me?` | status snapshot (`connect:true` ⇒ `session.connect`) including `product` + `capabilities` | | `session.connect` | — | status (`new` ⇒ implicit pair) | -| `session.pair` | — | status (no-op if already linked) | +| `session.pair` | `me?` | status (no-op if already linked) | | `session.disconnect` | — | status | | `session.logout` | — | status `new` | | `session.status` | — | `{me?, status, topics, product, identity, capabilities}` | @@ -873,6 +966,10 @@ If a section of a profile required per-product client method names, that profile | `unsubscribe` | `{topics}` | `{topics}` remaining | | `directory.list` | `{query?, kind?, limit?, cursor?}` | `{items, cursor?}` | | `directory.get` | `{id, icon?}` | `DirectoryRow` | +| `directory.find` | `{query?, kind?, limit?, cursor?}` | `{items, cursor?}` (not roster) | +| `directory.join` | `{id}` | `{topic}` | +| `directory.leave` | `{id}` | `{topic}` | +| `directory.create` | `{name, topic?}` | `{topic}` | | `messages.send` | `{to, contents, reply?, context?}` | `{id, topic}` | | `messages.read` | `{to, ids, by}` | `{topic}` | @@ -889,6 +986,8 @@ Canon: [`docs/WHATSBOX.md`](WHATSBOX.md), [`docs/RFC-1.md`](RFC-1.md). Library: | `product` | `whatsapp` | | `identity` | `user` | | `capabilities.auth` | `["qr"]` | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` (ContextInfo quote; `reply.text` stub; no `remoteJid` on same-chat quotes). Never emit `context`. | | `react` | `true` | | `read` | `"message"` | @@ -911,6 +1010,8 @@ Implementation difficulty: **3 / 5** (done in this repo). Hard parts are LID can | `product` | `discord` | | `identity` | `bot` | | `capabilities.auth` | `["token"]` | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` (`message_reference`) | | `react` | `true` (`PUT …/reactions/{emoji}/@me`) | | `read` | `"none"` — Discord [does not have read receipts](https://paul.koeck.dev/writeups/discord-read-receipts); bot ACK of a channel is not a user-visible tick | @@ -989,6 +1090,8 @@ Degraded client-visible behavior: `read: "none"` → `messages.read` errors `uns | `identity` | `bot` | `user` | | `profile` | `bot` | `user` | | `auth` | `["token"]` (both tokens in store) or `["oauth"]` | `["oauth"]` | +| `me` | `"issued"` | `"issued"` | +| `membership` | `"none"` | `"none"` | | `reply` | `"context"` | `"context"` | | `react` | `true` (`reactions.add` / `remove`) | `true` | | `read` | `"cursor"` | `"cursor"` | @@ -1064,6 +1167,8 @@ Personal Microsoft accounts are **not** supported by Teams chat Graph APIs. `ide | `product` | `teams` | `teams` | | `identity` | `user` | `bot` | | `auth` | `["device_code"]` or `["oauth"]` | `["token"]` (client credentials in store) + admin consent | +| `me` | `"issued"` | `"issued"` | +| `membership` | `"none"` | `"none"` | | `reply` | `"quote"` (`chatMessage` `replyToId` / replies collection) | `"quote"` | | `react` | `true` (`setReaction` / `unsetReaction`) | `true` | | `read` | `"conversation"` (`markChatReadForUser`, **beta**, delegated only) | `"none"` (application cannot mark a user’s chat read) | @@ -1152,6 +1257,8 @@ HTTP Bot API, token from [@BotFather](https://core.telegram.org/bots). `identity | `identity` | `bot` | | `profile` | `bot` | | `auth` | `["token"]` | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` (`reply_parameters` / `reply_to_message_id`) | | `react` | `true` (`setMessageReaction`) | | `read` | `"none"` (bots do not send user-visible read receipts) | @@ -1185,6 +1292,8 @@ Native client protocol (TDLib / Telethon / GramJS / MadelineProto). `identity: u | `identity` | `user` | | `profile` | `user` | | `auth` | `["qr"]` and/or `["token"]` (phone code is rendered as `qr` **or** `device_code` — see below) | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` | | `react` | `true` | | `read` | `"cursor"` (`messages.readHistory` / `channels.readHistory` — up-to id, not WhatsApp per-message blue ticks in groups) | @@ -1221,6 +1330,8 @@ A binary MAY support only `profile: bot`. A binary that supports both MUST adver | `product` | `matrix` | | `identity` | `user` | | `auth` | `["token"]` (access_token in store) and/or `["oauth"]` / password via `token_required` | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` (`m.in_reply_to` / `m.relates_to`) | | `react` | `true` (`m.reaction` + `m.annotation`) | | `read` | `"cursor"` (`POST …/receipt/m.read/{eventId}` — up-to event, threaded receipts exist but v1 sends unthreaded) | @@ -1284,33 +1395,95 @@ Degraded: encrypted rooms are visible in the directory but live as `kind: messag --- -## 20. Capability and difficulty matrix +## 20. Azure Web PubSub mapping + +**Official identity:** a Chat hub **user** (`userId` in a client access URL). There is no external bot portal: the client **claims** `me`. Adapter canon: [`docs/PUBSUBBOX.md`](PUBSUBBOX.md). Omit `profile` (implied Chat hub; a later `profile: "hub"` would be the base hub). + +| Field | Value | +|---|---| +| `product` | `webpubsub` | +| `identity` | `user` | +| `profile` | omit | +| `auth` | `["device_code", "token"]` | +| `me` | `"claimed"` | +| `membership` | `"create"` | +| `reply` | `"none"` | +| `react` | `false` | +| `read` | `"none"` | +| `ack` | `false` | +| `files` | `true` | +| `attachments` | `"single"` | + +### 20.1 Auth + +`session.pair({me})` (or `initialize` with `me` + `connect: true`). Missing `me` → `me_required`. + +Device code (`az-cli` inside the adapter) is `$session` `device_code`. A pre-minted client access URL in the store is `token` (skip Entra). Hub / resource are store-only: after Entra, one Chat hub → use it; otherwise `$session` `token_required` `{path:"credentials.json", hint:"hub"}`. No `hub` field on the RPC. + +`me` on the wire is the Chat `userId`, never the Entra UPN. + +### 20.2 Topics and directory + +Groups: Chat `roomId` → `kind: "group"`. 1:1: `directory.find` may return `{topic: userId, kind: "user"}`; `directory.join` opens/reuses the DM room, `$session` `remap` to the room topic. Roster key is the room. Subscribe requires a roster row (`not_found` otherwise). + +`directory.create` `{name, topic?}` is a group. `directory.find` / `join` / `leave` are live and online-only. + +Populate after `online`: rooms this `me` already belongs to. History is **not** live (§7.4). + +### 20.3 Live path + +Chat client WebSocket (reliable reconnect is catch-up on **current** subscriptions only). Local; no public webhook. Stdout is `event` notifications. + +### 20.4 Send / reply / react / read + +| RPC | Native | Degraded | +|---|---|---| +| send | Chat text, or one blob via the adapter’s storage overlay (PUBSUBBOX.md) | `attachments: "single"`. Caption = optional `text` part. Extra blob parts → `unsupported`. | +| reply | — | `unsupported` (`capability: "reply"`). Chat `CreateMessage` / `refMessageId` is not implemented. | +| react | — | `unsupported` (`capability: "react"`). | +| read | — | `unsupported` (`capability: "read"`). `chat.markRead` is not implemented. | + +### 20.5 Files + +`files: true`. Bytes never on JSON-RPC. The adapter uploads to the hub’s storage account and points the Chat message at the URL; inbound downloads into `files` then notifies. How that is stored on Chat (`content.binary`, blob metadata) is **not** client-visible — [`docs/PUBSUBBOX.md`](PUBSUBBOX.md). + +### 20.6 Difficulty + +**2 / 5.** Chat SDK + claimed `me` + membership verbs. Hard parts are Entra device-code pairing, hub selection, and the blob overlay. No hosted ingress. + +Degraded: no quotes, reactions, or receipts; one blob + optional caption; public blob URLs in v1 (SAS later). + +--- + +## 21. Capability and difficulty matrix Legend: **Diff** = implementation difficulty 1 (easiest) … 5 (hardest) for a gateway that already speaks this protocol. WhatsApp is the reference (already shipped). -| | WhatsApp | Discord | Slack | Teams | Telegram Bot | Telegram user | Matrix | -|---|---|---|---|---|---|---|---| -| **Official identity** | Linked device (user) | **Bot only** (self-bots forbidden) | Bot app (optional user token) | Graph delegated user (or app + admin consent) | BotFather bot | MTProto user client | CS API user | -| `product` | `whatsapp` | `discord` | `slack` | `teams` | `telegram` | `telegram` | `matrix` | -| `identity` | `user` | `bot` | `bot` (default) | `user` (default) | `bot` | `user` | `user` | -| **Auth** | QR (`qr`) | Token in store (`token_required`) | Token(s) or OAuth | Device code / OAuth / client secret | Bot token | API_ID + QR/code + 2FA | access_token / password / SSO | -| **Live path** | Local WS (whatsmeow) | Local Gateway WS (ingress does not replace it) | Socket Mode **or** HTTP Events API via hosted ingress | Hosted HTTPS `notificationUrl` → bus → bridge. Delta poll = air-gapped fallback only | `getUpdates` **or** `setWebhook` via ingress | Local MTProto updates | Local `/sync` long-poll (optional host fan-in) | -| **Reply** | Quote (ContextInfo) | `message_reference` | **`thread_ts` via `context` (not a quote)** | `replyToId` / channel replies + inbound `context` | `reply_parameters` | MTProto reply | `m.in_reply_to` + optional `m.thread` `context` | -| `reply` cap. | `"quote"` | `"quote"` | **`"context"`** | `"quote"` | `"quote"` | `"quote"` | `"quote"` | -| **Reactions** | yes | yes | yes (Slack names) | `setReaction` | `setMessageReaction` (inbound 1:1 hole) | yes | `m.reaction` | -| `react` | `true` | `true` | `true` | `true` | `true` | `true` | `true` | -| **Mark-read** | Per-message blue ticks | **None** | `conversations.mark` cursor | Whole-chat `markChatReadForUser` (beta, delegated); channels `unsupported` | **None** | History up-to id | `m.read` up-to event | -| `read` | `message` | **`none` → `unsupported`** | **`cursor` (not ticks)** | **`conversation` or `none`** | **`none` → `unsupported`** | `cursor` | `cursor` | -| **Acks to sender** | delivered/read/played | **none** | **none** | **none** | **none** | not mapped | `m.receipt` → `ack:read` | -| `ack` | `true` | `false` | `false` | `false` | `false` | `false` | `true` | -| **Files** | download then path | attachment URL → path | `files.uploadV2` | hosted contents | `getFile` (size cap) | MTProto download | `mxc://` | -| `files` | `true` | `true` | `true` | `true` | `true` | `true` | `true` | -| `attachments` | `"single"` | `"many"` | `"many"` | `"many"` | `"single"` | `"single"` | `"single"` | -| **E2EE** | Signal (library; not client-visible) | n/a | n/a | n/a | n/a | secret chats → `message`+`unknown` / `unsupported` | **Olm/Megolm out of v1** (per-room `message`+`unknown` / `unsupported`) | -| **Directory** | contacts + groups + HistorySync headers | guilds/channels bot is in | conversations token can see | `/me/chats` + joined teams | chats the bot has seen | dialogs + contacts | joined rooms + `m.direct` | -| **Diff** | 3 (shipped) | **2** | **3** | **4** | **1** | **4** | **3** (5 with crypto) | - -### 20.1 Gaps — degraded behavior (not “unsupported, ignore”) +| | WhatsApp | Discord | Slack | Teams | Telegram Bot | Telegram user | Matrix | Web PubSub | +|---|---|---|---|---|---|---|---|---| +| **Official identity** | Linked device (user) | **Bot only** (self-bots forbidden) | Bot app (optional user token) | Graph delegated user (or app + admin consent) | BotFather bot | MTProto user client | CS API user | Chat hub user (`userId` claimed) | +| `product` | `whatsapp` | `discord` | `slack` | `teams` | `telegram` | `telegram` | `matrix` | `webpubsub` | +| `identity` | `user` | `bot` | `bot` (default) | `user` (default) | `bot` | `user` | `user` | `user` | +| `me` cap. | `"issued"` | `"issued"` | `"issued"` | `"issued"` | `"issued"` | `"issued"` | `"issued"` | **`"claimed"`** | +| `membership` | `"none"` | `"none"` | `"none"` | `"none"` | `"none"` | `"none"` | `"none"` | **`"create"`** | +| **Auth** | QR (`qr`) | Token in store (`token_required`) | Token(s) or OAuth | Device code / OAuth / client secret | Bot token | API_ID + QR/code + 2FA | access_token / password / SSO | Device code + token | +| **Live path** | Local WS (whatsmeow) | Local Gateway WS (ingress does not replace it) | Socket Mode **or** HTTP Events API via hosted ingress | Hosted HTTPS `notificationUrl` → bus → bridge. Delta poll = air-gapped fallback only | `getUpdates` **or** `setWebhook` via ingress | Local MTProto updates | Local `/sync` long-poll (optional host fan-in) | Local Chat WebSocket | +| **Reply** | Quote (ContextInfo) | `message_reference` | **`thread_ts` via `context` (not a quote)** | `replyToId` / channel replies + inbound `context` | `reply_parameters` | MTProto reply | `m.in_reply_to` + optional `m.thread` `context` | **none** | +| `reply` cap. | `"quote"` | `"quote"` | **`"context"`** | `"quote"` | `"quote"` | `"quote"` | `"quote"` | **`"none"`** | +| **Reactions** | yes | yes | yes (Slack names) | `setReaction` | `setMessageReaction` (inbound 1:1 hole) | yes | `m.reaction` | **none** | +| `react` | `true` | `true` | `true` | `true` | `true` | `true` | `true` | **`false`** | +| **Mark-read** | Per-message blue ticks | **None** | `conversations.mark` cursor | Whole-chat `markChatReadForUser` (beta, delegated); channels `unsupported` | **None** | History up-to id | `m.read` up-to event | **None** | +| `read` | `message` | **`none` → `unsupported`** | **`cursor` (not ticks)** | **`conversation` or `none`** | **`none` → `unsupported`** | `cursor` | `cursor` | **`none` → `unsupported`** | +| **Acks to sender** | delivered/read/played | **none** | **none** | **none** | **none** | not mapped | `m.receipt` → `ack:read` | **none** | +| `ack` | `true` | `false` | `false` | `false` | `false` | `false` | `true` | `false` | +| **Files** | download then path | attachment URL → path | `files.uploadV2` | hosted contents | `getFile` (size cap) | MTProto download | `mxc://` | storage overlay (PUBSUBBOX.md) | +| `files` | `true` | `true` | `true` | `true` | `true` | `true` | `true` | `true` | +| `attachments` | `"single"` | `"many"` | `"many"` | `"many"` | `"single"` | `"single"` | `"single"` | `"single"` | +| **E2EE** | Signal (library; not client-visible) | n/a | n/a | n/a | n/a | secret chats → `message`+`unknown` / `unsupported` | **Olm/Megolm out of v1** (per-room `message`+`unknown` / `unsupported`) | n/a | +| **Directory** | contacts + groups + HistorySync headers | guilds/channels bot is in | conversations token can see | `/me/chats` + joined teams | chats the bot has seen | dialogs + contacts | joined rooms + `m.direct` | rooms this `me` is in; find/join/create | +| **Diff** | 3 (shipped) | **2** | **3** | **4** | **1** | **4** | **3** (5 with crypto) | **2** | + +### 21.1 Gaps — degraded behavior (not “unsupported, ignore”) | Gap | Advertisement | Client-visible behavior | |---|---|---| @@ -1325,25 +1498,30 @@ Legend: **Diff** = implementation difficulty 1 (easiest) … 5 (hardest) for a g | Telegram Bot vs user | `profile: "bot"` \| `"user"` | Bot cannot see the human’s other chats. User profile is a different store/identity. | | Telegram Bot: no read receipts | `read: "none"` | `messages.read` → `unsupported`. | | Telegram Bot: 1:1 inbound reactions | `react: true` (send works) | Inbound `kind: reaction` may never arrive in private chats. Client must tolerate silence. | +| Web PubSub: claimed `me` | `me: "claimed"` | Pass `me` on `initialize` / `session.pair`. Omit → `me_required`. | +| Web PubSub: no registrar | `membership: "create"` | `directory.find` / `join` / `leave` / `create`. Subscribe is not join. | +| Web PubSub: no quotes/reacts/receipts | `reply: "none"`, `react: false`, `read: "none"`, `ack: false` | Those RPCs → `unsupported`. | +| Web PubSub: one blob + caption | `attachments: "single"`, `files: true` | Extra blob parts → `unsupported`. Caption = optional `text` part (adapter stores it on the blob). | | Matrix E2EE rooms | (no capability; per-room) | Inbound ciphertext → `kind: message` + `unknown` `label: "encrypted"`. Send/read/react → `unsupported` (no `error.data.capability`). Room still listed. Plaintext rooms in the same session work. | | All: no history API | (v1 scope) | Late subscribe / restart: no replay. Initial `/sync` / delta / HistorySync bodies are not events. | -### 20.2 Suggested implementation order +### 21.2 Suggested implementation order 1. **Telegram Bot** (diff 1) — prove the common envelope on a second product with `getUpdates`. -2. **Discord Bot** (diff 2) — Gateway push, bot identity, `read: "none"` error path. -3. **Matrix unencrypted** (diff 3) — `/sync` cursor discipline, `ack: read`, E2EE degradation. -4. **Slack Bot + Socket Mode** (diff 3) — `reply: "context"`, inbound `context`, `read: "cursor"`. -5. **Telegram MTProto user** (diff 4) — user-companion parity with WhatsApp. -6. **Teams Graph** (diff 4) — hosted ingress + Entra + dual chat/channel. Delta-poll only as bring-up fallback. +2. **Web PubSub Chat** (diff 2) — claimed `me`, `membership: "create"`, `read: "none"` / `reply: "none"`. Canon: PUBSUBBOX.md. +3. **Discord Bot** (diff 2) — Gateway push, bot identity, `read: "none"` error path. +4. **Matrix unencrypted** (diff 3) — `/sync` cursor discipline, `ack: read`, E2EE degradation. +5. **Slack Bot + Socket Mode** (diff 3) — `reply: "context"`, inbound `context`, `read: "cursor"`. +6. **Telegram MTProto user** (diff 4) — user-companion parity with WhatsApp. +7. **Teams Graph** (diff 4) — hosted ingress + Entra + dual chat/channel. Delta-poll only as bring-up fallback. --- -## 21. Implementer notes (not the client contract) +## 22. Implementer notes (not the client contract) Facts an implementer should not rediscover. Not visible to the JSON-RPC client except as capabilities / errors already specified. -### 21.1 Hosted ingress +### 22.1 Hosted ingress - One hosted function MAY serve many local bridges: route on a store/session id in the bus topic / SignalR group / Service Bus session. - Verify Slack signing secret, Graph clientState, Telegram secret token **on the host**. Do not forward unverified bodies. @@ -1352,39 +1530,39 @@ Facts an implementer should not rediscover. Not visible to the JSON-RPC client e - Dedupe by native message id: Graph, Slack, and Service Bus are at-least-once. - Do not write client `files` from the host. Download in the process that owns `initialize.files`. -### 21.2 Discord +### 22.2 Discord - `Authorization: Bot {token}`. Never a user token. - `MESSAGE_CONTENT` is privileged; without it, `MESSAGE_CREATE` has empty `content` (map to an `unknown` part or empty `text` — prefer empty `text` only when blob parts still classify the message). - Threads are channels; v1 treats a thread id as its own `group` topic. Do not invent a thread RPC. Do not put that id in `context` on the parent. - No official read-receipt API for bots. Userdoccers `POST /channels/{id}/messages/{id}/ack` is a **user** read-state endpoint; using it with a bot token is not an official companion feature and MUST NOT be mapped to `messages.read`. -### 21.3 Slack +### 22.3 Slack - Socket Mode: ack `envelope_id` even when dropping for subscribe filters, or Slack retries. - `chat.postMessage` `thread_ts` starts or continues a thread; it does not attach a quote. Map `thread_ts` ↔ `context`. `reply_broadcast` is out of v1. Prefer client-supplied `context` over `reply.id` so a reply to a child does not become a new root. - `conversations.mark` requires membership; bot marks the **bot’s** cursor, which users do not see as ticks. -### 21.4 Teams +### 22.4 Teams - Resource data in change notifications is encrypted with a certificate you provision on the **host**. The local process should see decrypted `chatMessage` JSON (or notification-without-data + a Graph GET). - `markChatReadForUser` is **beta** and delegated. Treat 4xx as `unsupported` rather than looping. - Throttling is aggressive; honor `Retry-After`. Overflow still applies to the in-memory per-topic queue after an event is mapped. - Delta poll is an implementer fallback when `ingress` is missing from the store — not a client-visible mode. -### 21.5 Telegram +### 22.5 Telegram - Bot API `getUpdates` and webhooks are mutually exclusive; with ingress, call `setWebhook`, otherwise `getUpdates`. - Bot privacy mode in groups is a product setting, not a protocol switch. - MTProto: persist auth key; a second process with the same key is a second session (`store_locked` still applies locally). Ingress is irrelevant (updates are already a local socket). -### 21.6 Matrix +### 22.6 Matrix - Store `next_batch` even when offline so a crash does not re-emit a timeline. - `m.relates_to` for replies/reactions is plaintext even in encrypted rooms (server aggregations); v1 still does not decrypt bodies. - Media: authenticated media (`/_matrix/client/v1/media/download/…`) on modern homeservers; fall back to `/_matrix/media/v3/download/…`. -### 21.7 Stricter agnostic Reply (always send `context`) +### 22.7 Stricter agnostic Reply (always send `context`) §4.5 / §12 say: send `context: e.context ?? e.id` only when `capabilities.reply` is `"context"`. That is one branch on the send path. @@ -1400,15 +1578,22 @@ Keep advertising `"context"` anyway: the **UI** still needs to know that the fir Recommended for new unifying clients. Gateways MUST still ignore unknown `context` when `reply` is `"quote"`, and MUST prefer outbound `context` over `reply.id` when `reply` is `"context"`. +### 22.8 Web PubSub Chat + +Blob overlay, Entra/`az-cli` cache, hub `token_required`, and Chat invoke whitelist: [`docs/PUBSUBBOX.md`](PUBSUBBOX.md). Do not put Azure URLs or `content.binary` on JSON-RPC. + --- -## 22. Key trade-offs +## 23. Key trade-offs | Choice | Rejected | Why | |---|---|---| | One method table for every product | Per-product RPCs / MCP tools named after REST | A WhatsBox 0.1 codec must work. Gaps are capabilities + tokens. | | Opaque topics / `by` | Typed JIDs, snowflakes, MXIDs on the client | Client copies strings. Directory `kind` is the only enum. | -| Subscribe canonical-only | Alias / name subscribe | Search is `directory.list`. Ambiguous names need a picker. | +| Subscribe canonical-only | Alias / name subscribe | Roster search is `directory.list`. Live join lookup is `directory.find`. Ambiguous names need a picker. | +| Claimed `me` is pair input | `$session` `me_required` event / file watch | QR completes off-RPC; a nickname cannot. Error token `me_required`. | +| `membership` `none` \| `join` \| `create` | Subscribe-as-join, or a `create` boolean | Join is product membership. Create ⊃ join. WhatsApp stays `"none"`. | +| Web PubSub omits `profile` | `profile: "chat"` | Omitted ≡ Chat hub. A later base hub would set `profile: "hub"`. | | Store-based blobs | Base64 / multipart on JSON-RPC | Same-machine paths. No `files` ⇒ text-only. | | NDJSON, no batches, stderr = logs | LSP headers, JSON-RPC arrays, logs on stdout | WhatsBox 0.1 dialect. | | No `live` capability | `live: "push"` (constant) or `"poll"` | Inbound is always `event` notifications. A one-value capability that clients must ignore is not a capability. Fetch (WS / long-poll / ingress / delta) stays behind the daemon. | @@ -1423,11 +1608,11 @@ Recommended for new unifying clients. Gateways MUST still ignore unknown `contex --- -## 23. Relationship to WhatsBox +## 24. Relationship to WhatsBox -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. +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. [`docs/PUBSUBBOX.md`](PUBSUBBOX.md) is the **Azure Web PubSub Chat profile** for `pubsubbox`. Neither restates the method table, event envelope, or error tokens. -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. +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. A `pubsubbox` binary is conformant when it maps Chat as in PUBSUBBOX.md (claimed `me`, `membership: "create"`, files overlay). Wire version stays **`"0.1"`** so one codec spans the family. diff --git a/docs/PUBSUBBOX.md b/docs/PUBSUBBOX.md new file mode 100644 index 0000000..4cd6121 --- /dev/null +++ b/docs/PUBSUBBOX.md @@ -0,0 +1,226 @@ +# PubSubBox + +PubSubBox is the **Azure Web PubSub Chat adapter** for Inbox Client Protocol (ICP): binary `pubsubbox`. + +**Status:** v0.1 (not shipped) +**License (product):** MIT +**Chat API:** [Web PubSub chat](https://learn.microsoft.com/azure/azure-web-pubsub/chat-overview) (`2026-02-01-preview`) + +`pubsubbox` owns one Chat hub session (one claimed `userId`) and exposes it as JSON-RPC 2.0 NDJSON on stdio. It is **not** ICP, **not** the GrokBox appliance, and it does **not** bind HTTP or mDNS. + +Wire, methods, events, files, errors, client-once rules, and capabilities are specified in **INBOX.md** §20. This document is the Chat mapping: Entra/`az-cli`, hub selection, room ids, the blob overlay, and invoke names that are **not** implemented. It does **not** restate the method table. + +Hosted ingress in INBOX.md §2.5 (Azure Web PubSub as a **bus** for Graph/Slack webhooks) is a different use of the same Azure product. This adapter is a **Chat hub participant**, not that bus. + +--- + +## 1. Product + +### 1.1 What it is + +A locked Chat hub companion that is: + +1. An **address book** (rooms this `me` belongs to; find/join/create when advertised). +2. A **live pub/sub** of chats the client asked for. +3. A **same-machine blob channel** (paths on disk; Chat stores a URL, not bytes). + +One binary. One process. One store. One Chat `userId`. One hub. + +### 1.2 Who it is for + +The same ICP **client** as WhatsBox (`InboxClient`, a REPL, an appliance). Pairing UX (device code, claimed name, hub file) is the client’s job. The box does not host `grokbox.local`. + +### 1.3 v1 does + +- Pair via Entra device code (`az-cli`) and/or a token already in the store. +- Claim `me` (Chat `userId`) on `initialize` / `session.pair`. +- Connect, auto-reconnect, disconnect, logout. +- Directory populate + list/get + live `$directory`. +- `directory.find` / `join` / `leave` / `create` (`membership: "create"`). +- Subscribe by canonical room topic (roster row required). +- Send text; send one blob + optional caption via the storage overlay. +- Receive live `kind: message` / `meta` (membership). + +### 1.4 v1 does not + +- Quotes, reactions, mark-read, acks (`chat.createMessage` / `markRead` / `react` are `UnsupportedOperation` on the service). +- Message history as live events (Chat history exists; ICP v1 does not emit it except short reconnect catch-up on **current** subscriptions). +- Base-hub groups/connections (`profile: "hub"` — later; omit `profile` ≡ this Chat surface). +- HTTP operator UI, Bonjour, ARM picker RPCs. +- SAS blob URLs (public blobs in v1). +- More than one blob part per send. + +--- + +## 2. Chat profile (on the wire) + +| Field | Value | +|---|---| +| `product` | `webpubsub` | +| `identity` | `user` | +| `profile` | **omit** (≡ Chat hub) | +| `capabilities.auth` | `["device_code", "token"]` | +| `me` | `"claimed"` | +| `membership` | `"create"` | +| `reply` | `"none"` | +| `react` | `false` | +| `read` | `"none"` | +| `ack` | `false` | +| `files` | `true` | +| `attachments` | `"single"` | + +Advertise this object on `initialize` / `session.status`. Never emit `context`. Outbound `context` / `reply` / `reaction` parts → `unsupported` as INBOX.md requires. + +--- + +## 3. Process and store + +Invocation is INBOX.md §2.1 (`pubsubbox [--store ABSOLUTE_PATH]`). + +| Path | Contents | +|---|---| +| `/LOCK` | Exclusive lock | +| `/az/` | `az-cli` token cache (Entra). **Not** `~/.azure`. Logout wipes it. | +| `/credentials.json` | Hub name, optional client access URL, optional connection string / Entra app settings. Never on JSON-RPC. | +| `/directory.db` | Roster only. No messages. | + +`session.logout` deletes Entra cache, credentials identity, directory, subscriptions. The Chat service is **not** told to delete rooms. + +--- + +## 4. Pairing + +### 4.1 Claimed `me` + +Required on `initialize` and/or `session.pair`. Omit → `me_required` immediately (no `$session` event). Issued products passing `me` is N/A here. `me` is the Chat `userId` (opaque string the token will stamp). Not the Entra UPN, not `deviceName`. + +Initialize `{me:"alice", connect:false}` remembers `alice` for the later pair. Both set and different → `invalid_params`. + +### 4.2 Entra device code + +Inside `pair({me})` after `me` is known: + +1. `az-cli` `StartDeviceCode` (implementation PackageReference; not an ICP adapter). +2. Emit `$session` `device_code` `{user_code, verification_uri, expires_in, interval}`. +3. Poll until token or `pair_error`. + +Scopes: ARM as needed to list Web PubSub / Chat hubs; Chat data plane as required to mint a client access URL. + +If `credentials.json` already has a usable **client access URL** for this `me` + hub, skip device code (`auth` includes `"token"`). + +### 4.3 Hub / resource + +Store-only. After Entra: + +- Exactly one Chat hub on the reachable resource → write it and continue. +- Zero → `pair_error`. +- Many, and no `hub` in the store → `$session` `token_required` `{path:"credentials.json", hint:"hub"}` and wait (existing file-watch). The client (appliance, REPL) writes `{"hub":"…"}`. No `hub` param on initialize. + +Mint the Chat client access URL with `userId = me`. Then Chat WebSocket login → `paired` `{me}` → `online`. + +--- + +## 5. Topics + +| Entity | Canonical topic | `kind` | +|---|---|---| +| Room | Chat `roomId` (1–64: letters, digits, `_`, `-`) | `group` | +| User (find only) | Chat `userId` | `user` | +| 1:1 after join | The DM **room** id | `user` or `group` per Chat’s shape; roster key is the **room** | + +`directory.find` `{kind:"user"}` may return `{topic: userId, kind: "user"}`. `directory.join` of that topic opens or reuses the 1:1 room, then `$session` `remap` `{from: userId, to: roomId}`. Send/subscribe after that use `roomId`. + +`directory.create` `{name, topic?}` → Chat `createRoom`. Same `me` + same `topic` → no-op. Other occupant → `topic_taken`. Always a group. + +`directory.join` / `leave` `{id}`: canonical only. Already a member → no-op. Leave of a non-member → `not_found`. Leave drops a held subscription. + +Subscribe without a roster row → `not_found`. Join does not subscribe. + +--- + +## 6. Live path + +Chat client WebSocket after login. Filter by the current subscribe set. Unsubscribed rooms: protocol-ack if required, then drop; no download. + +First login / history pages are **not** live (INBOX.md §7.4). Persist whatever cursor Chat gives; emit only subsequent events. Reliable reconnect may fill **current** subscriptions (catch-up), not a history API. + +Membership events → chat `kind: meta` (`join` / `leave`) when already subscribed; `$directory` upsert/remove always as INBOX.md §7.2. + +No `ack` events. No `kind: reaction`. + +--- + +## 7. Files overlay + +Chat has **no** upload API (`chat.upload`, `chat.getUploadUrl`, `CreateMessage` External → `UnsupportedOperation`). `content.text` and `content.binary` **cannot coexist** (one table `Body` + `BodyType`). Message content cap is 64 KB. + +**Convention (adapter-internal; not on JSON-RPC):** + +1. Upload the file to the **same storage account** bound to the Chat hub (v1: public container, `publicAccess: blob`). +2. Set blob `Content-Type` from the file; `Content-Disposition` filename when known; **one** metadata key `x-ms-meta-text` = the ICP caption (UTF-8). Azure allows many `x-ms-meta-*` keys; this profile uses **only** `text` so caption round-trips in one header. +3. Chat message: `content.binary = base64(utf8(blobUrl))`. No `content.text`. A raw URL in `binary` is **400**. +4. Text-only send: `content.text`, `BodyType` text. No blob. + +**Send (ICP → Chat):** + +| ICP `contents` | Chat | +|---|---| +| `[{type:text}]` | `content.text` | +| one blob part, no text | upload; `binary` = base64(url); no `x-ms-meta-text` | +| text + one blob | upload; `x-ms-meta-text` = text; `binary` = base64(url) | +| two blob parts | `unsupported` (`attachments`) | +| blob without `initialize.files` | `files_required` | + +Download inbound **immediately** into `{files}/in/{safeTopic}/{id}[.ext]`, then emit the event (SAS later must not race). Public URL in v1; the client still uses the local path after notify. + +**Receive (Chat → ICP):** + +1. If `content.text` set and no usable `binary` → `[{type:text}]`. +2. If `binary` decodes as UTF-8 `https://…` URL: + - HEAD (need `x-ms-version` for metadata). + - Map `Content-Type` → part `type`: `image/*` → `image` (optional: `image/webp` → `sticker`), `video/*` → `video`, `audio/*` → `audio`, else `document`. + - GET into `files`; `path` on the part. + - If `x-ms-meta-text` present → also a `text` part (caption). +3. Else `binary` that is not a URL → `unknown` (no blob), or write raw bytes as `document` if it is clearly a small in-table payload. Prefer not to pretend in-table bytes are files. +4. Failed download → part with `error`, no `path`. + +Unsubscribed inbound: never download (INBOX.md §9). + +v1 does **not** use SAS. A later profile may. Do not put the Azure URL on the JSON-RPC event. + +--- + +## 8. Chat invoke whitelist (live `grokbox` hub) + +Implemented: `chat.login`, `chat.createRoom`, `chat.getRoom`, `chat.sendTextMessage`, `chat.queryMessageHistory`, member add/remove as the SDK exposes. + +**Not implemented** (`UnsupportedOperation` — same as a made-up `chat.*` name): `chat.createMessage`, `chat.sendMessage`, `chat.markRead`, `chat.react`, `chat.upload`, `chat.getUploadUrl`, and the rest of the upload/attachment family. + +Do not advertise capabilities that require those invokes. REST PATCH can set `content.binary`; that is an adapter implementation detail for the overlay, not a client RPC. + +--- + +## 9. Errors (adapter) + +All INBOX.md tokens. Additionally: + +| Situation | Token | +|---|---| +| Claimed pair, no `me` | `me_required` | +| Create topic held by someone else | `topic_taken` | +| Find/join/leave/create while not `online` | `disconnected` | +| Reply / react / read | `unsupported` + `error.data.capability` | + +`az-cli` failures during pair → `pair_error`. + +--- + +## 10. What stays off the wire + +Azure resource ids, connection strings, `content.binary`, blob URLs, `x-ms-meta-text`, ARM, `az-cli`, hub names (except via `token_required` hint + store file). The JSON-RPC client copies opaque topics and `me`, renders `device_code` / `token_required`, and calls find/join/create as INBOX.md. + +--- + +## 11. Difficulty + +**2 / 5.** Second-product proof for claimed `me` and membership verbs. Pairing is Entra + hub file, not QR. Files are a storage convention, not a Chat feature. diff --git a/docs/WHATSBOX.md b/docs/WHATSBOX.md index e082d7a..2765d91 100644 --- a/docs/WHATSBOX.md +++ b/docs/WHATSBOX.md @@ -66,6 +66,8 @@ Agents and local apps that can spawn a process, speak newline-delimited JSON-RPC | `identity` | `user` | | `profile` | omit (same as `identity`) | | `capabilities.auth` | `["qr"]` | +| `me` | `"issued"` | +| `membership` | `"none"` | | `reply` | `"quote"` | | `react` | `true` | | `read` | `"message"` | diff --git a/docs/adr/0001-claimed-me-is-pair-input.md b/docs/adr/0001-claimed-me-is-pair-input.md new file mode 100644 index 0000000..a266781 --- /dev/null +++ b/docs/adr/0001-claimed-me-is-pair-input.md @@ -0,0 +1,5 @@ +# Claimed `me` is pair input, not a `$session` wait + +QR and device-code complete off-RPC (phone scan, Microsoft’s page). A claimed `me` cannot: the product does not know the name until the client sends it, and JSON-RPC cannot patch an in-flight `pair`. We require `me` on `initialize` / `session.pair` when `capabilities.me` is `claimed`; omit → error token `me_required`. No `me_required` event and no `token_required`-style file watch for a nickname. Issued products that receive `me` must not pretend to honor it. + +**Considered:** hang `pair` and inject `me` later; clone `token_required`; a second `session.claim` RPC. All are extra channels for a field the client can already pass, and they fight `connect:true` one-shot. diff --git a/docs/adr/0002-membership-is-a-total-order.md b/docs/adr/0002-membership-is-a-total-order.md new file mode 100644 index 0000000..fe60b41 --- /dev/null +++ b/docs/adr/0002-membership-is-a-total-order.md @@ -0,0 +1,5 @@ +# Membership is `none` | `join` | `create`, and subscribe is not join + +Some products have no join API (WhatsApp — the phone is the registrar). Some can join but not create. Pubsubbox has no external registrar, so ICP must create. One string enum with implication (`create` ⊃ `join` ⊃ roster) advertises that without a boolean soup or an `auth`-style array. `directory.find` / `join` / `leave` / `create` are the verbs; `subscribe` stays live-event intent (RFC-1). Join does not subscribe; leave drops a held subscription because the topic is gone. + +**Considered:** `membership: join` plus a separate `create` boolean (can contradict); a membership array (`find`/`join`/`leave`/`create`); overloading `subscribe` or `messages.send` to create chats (ghost topics, RFC-1). diff --git a/docs/adr/0003-pubsub-blobs-are-url-in-content-binary.md b/docs/adr/0003-pubsub-blobs-are-url-in-content-binary.md new file mode 100644 index 0000000..ff29f34 --- /dev/null +++ b/docs/adr/0003-pubsub-blobs-are-url-in-content-binary.md @@ -0,0 +1,5 @@ +# Pubsubbox files are a blob URL in Chat `content.binary` + +Chat has no upload API (`chat.upload` / `CreateMessage` External are `UnsupportedOperation`). `content.text` and `content.binary` cannot coexist (one table `Body` + `BodyType`). We upload the file to the hub’s storage account, put `base64(utf8(url))` in `content.binary`, and put the ICP caption on the blob as `x-ms-meta-text`. Pubsubbox maps that to `files: true` / `attachments: "single"` in both directions. The JSON-RPC client never sees Azure URLs or metadata keys. + +**Considered:** base64 of the whole file in `content.binary` (64 KB cap); URL in `content.text` and sniff (collides with ordinary links); advertise `files: false`. diff --git a/docs/adr/0004-omit-profile-means-chat.md b/docs/adr/0004-omit-profile-means-chat.md new file mode 100644 index 0000000..35479f5 --- /dev/null +++ b/docs/adr/0004-omit-profile-means-chat.md @@ -0,0 +1,3 @@ +# Pubsubbox omits `profile`; omitted ≡ chat + +`product: "webpubsub"` with no `profile` is the Chat hub. A later base-hub adapter on the same product string would advertise `profile: "hub"`. Telegram still uses `profile` when one binary is bot or user. We do not emit `profile: "chat"` just to have a string. diff --git a/readme.md b/readme.md index b2f68c8..9e6efd7 100644 --- a/readme.md +++ b/readme.md @@ -123,13 +123,16 @@ no connect. Pass `InitializeOptions` for blobs, initial topics, or |---|---|---| | `InitializeAsync` | `initialize` | `SessionSnapshot` | | `ConnectAsync` | `session.connect` | `SessionSnapshot` | -| `PairAsync` | `session.pair` | `SessionSnapshot` | +| `PairAsync` | `session.pair` | `SessionSnapshot` (`PairAsync(me)` when `capabilities.me` is `claimed`) | | `DisconnectAsync` | `session.disconnect` | `SessionSnapshot` | | `LogoutAsync` | `session.logout` | `SessionSnapshot` (`new`) | | `StatusAsync` | `session.status` | `SessionSnapshot` | | `SubscribeAsync` / `UnsubscribeAsync` | `subscribe` / `unsubscribe` | `TopicsResult` (canonical topics) | | `ListDirectoryAsync` | `directory.list` | `DirectoryListResult` | | `GetDirectoryAsync` | `directory.get` | `DirectoryRow` | +| `FindDirectoryAsync` | `directory.find` | `DirectoryListResult` | +| `JoinDirectoryAsync` / `LeaveDirectoryAsync` | `directory.join` / `directory.leave` | `TopicResult` | +| `CreateDirectoryAsync` | `directory.create` | `TopicResult` | | `SendAsync` / `ReactAsync` | `messages.send` | `SendResult` (`Id`, canonical `Topic`) | | `ReadAsync` | `messages.read` | `ReadResult` | diff --git a/src/Inbox/Capabilities.cs b/src/Inbox/Capabilities.cs new file mode 100644 index 0000000..264b24d --- /dev/null +++ b/src/Inbox/Capabilities.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Serialization; + +namespace Inbox; + +/// INBOX.md capabilities.auth member. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthKind +{ + /// WhatsApp-style QR. + [JsonStringEnumMemberName("qr")] + Qr, + + /// OAuth authorization URL on $session. + [JsonStringEnumMemberName("oauth")] + Oauth, + + /// OAuth device grant (user_code + verification_uri). + [JsonStringEnumMemberName("device_code")] + DeviceCode, + + /// Token file in the store (token_required). + [JsonStringEnumMemberName("token")] + Token, +} + +/// INBOX.md capabilities.me: who binds session me. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MeBinding +{ + /// The product supplies me after auth. + [JsonStringEnumMemberName("issued")] + Issued, + + /// The client supplies me on initialize / session.pair. + [JsonStringEnumMemberName("claimed")] + Claimed, +} + +/// INBOX.md capabilities.membership. Total order: ⊃ roster. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MembershipCapability +{ + /// Find/join/leave/create are unsupported. + [JsonStringEnumMemberName("none")] + None, + + /// directory.find, join, leave. + [JsonStringEnumMemberName("join")] + Join, + + /// plus directory.create. + [JsonStringEnumMemberName("create")] + Create, +} + +/// INBOX.md capabilities.reply. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReplyCapability +{ + /// In-chat quote. Outbound context ignored. + [JsonStringEnumMemberName("quote")] + Quote, + + /// Grouping key (Slack threads), not a quote bubble. + [JsonStringEnumMemberName("context")] + Context, + + /// messages.send with reply is unsupported. + [JsonStringEnumMemberName("none")] + None, +} + +/// INBOX.md capabilities.read. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReadCapability +{ + /// Per-id receipts (WhatsApp blue ticks). + [JsonStringEnumMemberName("message")] + Message, + + /// Conversation read cursor (Slack conversations.mark). + [JsonStringEnumMemberName("cursor")] + Cursor, + + /// Whole-chat mark (Teams). + [JsonStringEnumMemberName("conversation")] + Conversation, + + /// messages.read is unsupported. + [JsonStringEnumMemberName("none")] + None, +} + +/// INBOX.md capabilities.attachments: blob-part cardinality on one send. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AttachmentsCapability +{ + /// Any blob part is unsupported. + [JsonStringEnumMemberName("none")] + None, + + /// At most one blob part; optional text is the caption. + [JsonStringEnumMemberName("single")] + Single, + + /// N blob parts, still one Box {id, topic}. + [JsonStringEnumMemberName("many")] + Many, +} + +/// INBOX.md capabilities object on initialize / session.status. +public sealed record Capabilities +{ + /// How pair authenticates. + public IReadOnlyList Auth { get; init; } = []; + + /// How session me is bound. + public MeBinding? Me { get; init; } + + /// Product membership verbs. + public MembershipCapability? Membership { get; init; } + + /// How messages.send reply behaves. + public ReplyCapability? Reply { get; init; } + + /// Whether a reaction content part is supported. + public bool React { get; init; } + + /// How messages.read behaves. + public ReadCapability? Read { get; init; } + + /// Whether kind: ack events are emitted. + public bool Ack { get; init; } + + /// Whether the product can move blobs through initialize.files. + public bool Files { get; init; } + + /// How many blob parts one kind: message may carry. + public AttachmentsCapability? Attachments { get; init; } +} diff --git a/src/Inbox/InboxClient.cs b/src/Inbox/InboxClient.cs index 0aa56be..bebb12c 100644 --- a/src/Inbox/InboxClient.cs +++ b/src/Inbox/InboxClient.cs @@ -87,10 +87,22 @@ public Task InitializeAsync(InitializeOptions options, Cancella public Task ConnectAsync(CancellationToken cancellationToken = default) => InvokeAsync("session.connect", InboxJsonContext.Default.SessionSnapshot, cancellationToken); - /// Inbox Protocol session.pair. + /// Inbox Protocol session.pair. Issued products omit me. public Task PairAsync(CancellationToken cancellationToken = default) => InvokeAsync("session.pair", InboxJsonContext.Default.SessionSnapshot, cancellationToken); + /// Inbox Protocol session.pair with a claimed . + public Task PairAsync(string me, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(me); + return InvokeAsync( + "session.pair", + new SessionPairParams { Me = me }, + JsonRpcContext.Default.JsonRpcRequestSessionPairParams, + InboxJsonContext.Default.SessionSnapshot, + cancellationToken); + } + /// Inbox Protocol session.disconnect. public Task DisconnectAsync(CancellationToken cancellationToken = default) => InvokeAsync("session.disconnect", InboxJsonContext.Default.SessionSnapshot, cancellationToken); @@ -155,6 +167,54 @@ public Task GetDirectoryAsync(string id, bool? icon, CancellationT cancellationToken); } + /// Inbox Protocol directory.find (live product lookup; not the roster). + public Task FindDirectoryAsync(DirectoryListOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= new DirectoryListOptions(); + return InvokeAsync( + "directory.find", + options, + JsonRpcContext.Default.JsonRpcRequestDirectoryListOptions, + InboxJsonContext.Default.DirectoryListResult, + cancellationToken); + } + + /// Inbox Protocol directory.join. Canonical topic only. Does not subscribe. + public Task JoinDirectoryAsync(string id, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + return InvokeAsync( + "directory.join", + new DirectoryTopicParams { Id = id }, + JsonRpcContext.Default.JsonRpcRequestDirectoryTopicParams, + InboxJsonContext.Default.TopicResult, + cancellationToken); + } + + /// Inbox Protocol directory.leave. Canonical topic only. Drops a held subscription. + public Task LeaveDirectoryAsync(string id, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + return InvokeAsync( + "directory.leave", + new DirectoryTopicParams { Id = id }, + JsonRpcContext.Default.JsonRpcRequestDirectoryTopicParams, + InboxJsonContext.Default.TopicResult, + cancellationToken); + } + + /// Inbox Protocol directory.create. Always a group. Does not subscribe. + public Task CreateDirectoryAsync(string name, string? topic = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return InvokeAsync( + "directory.create", + new DirectoryCreateParams { Name = name, Topic = topic }, + JsonRpcContext.Default.JsonRpcRequestDirectoryCreateParams, + InboxJsonContext.Default.TopicResult, + cancellationToken); + } + /// Inbox Protocol messages.send. public Task SendAsync( string to, diff --git a/src/Inbox/InboxJsonContext.cs b/src/Inbox/InboxJsonContext.cs index 067a9b2..0e88b81 100644 --- a/src/Inbox/InboxJsonContext.cs +++ b/src/Inbox/InboxJsonContext.cs @@ -10,23 +10,37 @@ namespace Inbox; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - UseStringEnumConverter = false, + UseStringEnumConverter = true, AllowOutOfOrderMetadataProperties = true)] [JsonSerializable(typeof(InitializeOptions))] +[JsonSerializable(typeof(SessionPairParams))] [JsonSerializable(typeof(DirectoryListOptions))] [JsonSerializable(typeof(DirectoryGetParams))] +[JsonSerializable(typeof(DirectoryTopicParams))] +[JsonSerializable(typeof(DirectoryCreateParams))] [JsonSerializable(typeof(TopicsParams))] [JsonSerializable(typeof(MessagesSendParams))] [JsonSerializable(typeof(MessagesReadParams))] [JsonSerializable(typeof(MessageReply))] [JsonSerializable(typeof(SessionSnapshot))] [JsonSerializable(typeof(Capabilities))] +[JsonSerializable(typeof(AuthKind))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(MeBinding))] +[JsonSerializable(typeof(MembershipCapability))] +[JsonSerializable(typeof(ReplyCapability))] +[JsonSerializable(typeof(ReadCapability))] +[JsonSerializable(typeof(AttachmentsCapability))] +[JsonSerializable(typeof(SessionStatus))] +[JsonSerializable(typeof(Identity))] +[JsonSerializable(typeof(DirectoryKind))] [JsonSerializable(typeof(TopicsResult))] [JsonSerializable(typeof(DirectoryListResult))] [JsonSerializable(typeof(DirectoryRow))] [JsonSerializable(typeof(DirectoryParticipant))] [JsonSerializable(typeof(SendResult))] [JsonSerializable(typeof(ReadResult))] +[JsonSerializable(typeof(TopicResult))] [JsonSerializable(typeof(ChatEvent))] [JsonSerializable(typeof(ChatMessage))] [JsonSerializable(typeof(ChatReaction))] diff --git a/src/Inbox/JsonRpcContext.cs b/src/Inbox/JsonRpcContext.cs index c598ac4..e74742f 100644 --- a/src/Inbox/JsonRpcContext.cs +++ b/src/Inbox/JsonRpcContext.cs @@ -6,12 +6,16 @@ namespace Inbox; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - GenerationMode = JsonSourceGenerationMode.Metadata)] + GenerationMode = JsonSourceGenerationMode.Metadata, + UseStringEnumConverter = true)] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(ContentPart))] diff --git a/src/Inbox/Requests.cs b/src/Inbox/Requests.cs index 14d66f9..7489eb1 100644 --- a/src/Inbox/Requests.cs +++ b/src/Inbox/Requests.cs @@ -29,16 +29,30 @@ public sealed record InitializeOptions /// Default : whatsbox on {}. public static string DefaultDeviceName { get; } = $"whatsbox on {Environment.MachineName}"; + + /// + /// Claimed product identity (capabilities.me is claimed). + /// Issued products must omit this; WhatsApp returns invalid_params. + /// Remembered for a later session.pair when is false. + /// + public string? Me { get; init; } +} + +/// INBOX.md §6.3 session.pair parameters. +public sealed record SessionPairParams +{ + /// Claimed product identity. Issued products must omit. + public string? Me { get; init; } } /// INBOX.md §5.8 / §13 directory.list parameters. public sealed record DirectoryListOptions { - /// Optional match against name, pn, handle, and JID string. + /// Optional match against name, pn, handle, and topic string. public string? Query { get; init; } - /// Optional. user or group. - public string? Kind { get; init; } + /// Optional. or . + public DirectoryKind? Kind { get; init; } /// Page size. Implementation default applies when omitted. public int? Limit { get; init; } @@ -70,6 +84,23 @@ public sealed record DirectoryGetParams public bool? Icon { get; init; } } +/// INBOX.md directory.join / directory.leave params. +public sealed record DirectoryTopicParams +{ + /// Canonical topic only. + public required string Id { get; init; } +} + +/// INBOX.md directory.create params. +public sealed record DirectoryCreateParams +{ + /// Group display name. + public required string Name { get; init; } + + /// Optional claimed topic. Omit to let the product assign one. + public string? Topic { get; init; } +} + /// INBOX.md messages.send params. public sealed record MessagesSendParams { diff --git a/src/Inbox/Results.cs b/src/Inbox/Results.cs index 825b3bf..f3070f4 100644 --- a/src/Inbox/Results.cs +++ b/src/Inbox/Results.cs @@ -1,58 +1,74 @@ +using System.Text.Json.Serialization; + namespace Inbox; +/// INBOX.md session status. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionStatus +{ + /// No session in the store. + [JsonStringEnumMemberName("new")] + New, + + /// Keys/tokens exist, connection down. + [JsonStringEnumMemberName("offline")] + Offline, + + /// Send and receive are up. + [JsonStringEnumMemberName("online")] + Online, +} + +/// INBOX.md identity. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Identity +{ + [JsonStringEnumMemberName("user")] + User, + + [JsonStringEnumMemberName("bot")] + Bot, +} + +/// INBOX.md directory kind: user or group. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DirectoryKind +{ + [JsonStringEnumMemberName("user")] + User, + + [JsonStringEnumMemberName("group")] + Group, +} + /// Result of initialize and the session.* methods. public sealed record SessionSnapshot { - /// new, offline, or online. - public required string Status { get; init; } + /// , , or . + public required SessionStatus Status { get; init; } /// Current subscription set. Always includes $session. public IReadOnlyList Topics { get; init; } = []; - /// Paired account LID. Omitted when is new. + /// Paired product identity. Omitted when is . public string? Me { get; init; } - /// Protocol version, present on initialize (and connect-as-init). + /// Protocol version, present on initialize (and connect-as-init). v1 is 0.1. public string? Version { get; init; } - /// INBOX.md product id, e.g. whatsapp. + /// INBOX.md product id (e.g. whatsapp, webpubsub). public string? Product { get; init; } - /// INBOX.md identity, e.g. user or bot. - public string? Identity { get; init; } + /// INBOX.md identity. + public Identity? Identity { get; init; } - /// Optional stack when it differs from . + /// Optional stack when it differs from (e.g. hub). public string? Profile { get; init; } /// Advertised capabilities. Required on a conformant daemon. public Capabilities? Capabilities { get; init; } } -/// INBOX.md capabilities object on initialize / session.status. -public sealed record Capabilities -{ - /// How pair authenticates: qr, oauth, device_code, token. - public IReadOnlyList Auth { get; init; } = []; - - /// quote, context, or none. - public string? Reply { get; init; } - - /// Whether a reaction content part is supported. - public bool React { get; init; } - - /// message, cursor, conversation, or none. - public string? Read { get; init; } - - /// Whether kind: ack events are emitted. - public bool Ack { get; init; } - - /// Whether the product can move blobs through initialize.files. - public bool Files { get; init; } - - /// none, single, or many. - public string? Attachments { get; init; } -} - /// Result of subscribe / unsubscribe. public sealed record TopicsResult { @@ -76,8 +92,8 @@ public sealed record DirectoryRow /// Canonical JID. public required string Topic { get; init; } - /// user or group. - public required string Kind { get; init; } + /// or . + public required DirectoryKind Kind { get; init; } /// Best display name. public string? Name { get; init; } @@ -136,7 +152,14 @@ public sealed record SendResult /// Result of messages.read. public sealed record ReadResult { - /// Canonical chat JID. + /// Canonical chat topic. + public required string Topic { get; init; } +} + +/// Result of directory.join, directory.leave, and directory.create. +public sealed record TopicResult +{ + /// Canonical chat topic. public required string Topic { get; init; } } diff --git a/src/Tests/InboxClientTests.cs b/src/Tests/InboxClientTests.cs index 053655a..a807d04 100644 --- a/src/Tests/InboxClientTests.cs +++ b/src/Tests/InboxClientTests.cs @@ -93,7 +93,7 @@ public async Task Demuxes_event_and_rpc_reply_on_the_same_pipe() Assert.Equal("2@live", qr.Code); var status = await statusTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal("new", status.Status); + Assert.Equal(SessionStatus.New, status.Status); Assert.Contains("$session", status.Topics); stdout.Complete(); @@ -154,6 +154,68 @@ public async Task GetDirectory_omits_icon_when_null_and_writes_when_set() stdout.Complete(); } + [Fact] + public async Task Directory_membership_and_claimed_pair_write_spec_methods() + { + var stdout = new LineSource(); + var stdin = new LineSink(); + await using var client = new InboxClient(stdout, stdin); + + var find = client.FindDirectoryAsync(new DirectoryListOptions { Query = "falcon", Kind = DirectoryKind.Group }); + var findLine = await stdin.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using (var req = JsonDocument.Parse(findLine)) + { + Assert.Equal("directory.find", req.RootElement.GetProperty("method").GetString()); + var p = req.RootElement.GetProperty("params"); + Assert.Equal("falcon", p.GetProperty("query").GetString()); + Assert.Equal("group", p.GetProperty("kind").GetString()); + stdout.WriteLine($"{{\"jsonrpc\":\"2.0\",\"id\":\"{req.RootElement.GetProperty("id").GetString()}\",\"result\":{{\"items\":[{{\"topic\":\"falcon\",\"kind\":\"group\",\"name\":\"Falcon\"}}]}}}}"); + } + Assert.Equal("falcon", Assert.Single((await find.WaitAsync(TimeSpan.FromSeconds(5))).Items).Topic); + + var join = client.JoinDirectoryAsync("falcon"); + var joinLine = await stdin.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using (var req = JsonDocument.Parse(joinLine)) + { + Assert.Equal("directory.join", req.RootElement.GetProperty("method").GetString()); + Assert.Equal("falcon", req.RootElement.GetProperty("params").GetProperty("id").GetString()); + stdout.WriteLine($"{{\"jsonrpc\":\"2.0\",\"id\":\"{req.RootElement.GetProperty("id").GetString()}\",\"result\":{{\"topic\":\"falcon\"}}}}"); + } + Assert.Equal("falcon", (await join.WaitAsync(TimeSpan.FromSeconds(5))).Topic); + + var create = client.CreateDirectoryAsync("Project Falcon", topic: "falcon"); + var createLine = await stdin.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using (var req = JsonDocument.Parse(createLine)) + { + Assert.Equal("directory.create", req.RootElement.GetProperty("method").GetString()); + var p = req.RootElement.GetProperty("params"); + Assert.Equal("Project Falcon", p.GetProperty("name").GetString()); + Assert.Equal("falcon", p.GetProperty("topic").GetString()); + stdout.WriteLine($"{{\"jsonrpc\":\"2.0\",\"id\":\"{req.RootElement.GetProperty("id").GetString()}\",\"result\":{{\"topic\":\"falcon\"}}}}"); + } + Assert.Equal("falcon", (await create.WaitAsync(TimeSpan.FromSeconds(5))).Topic); + + var leave = client.LeaveDirectoryAsync("falcon"); + var leaveLine = await stdin.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using (var req = JsonDocument.Parse(leaveLine)) + { + Assert.Equal("directory.leave", req.RootElement.GetProperty("method").GetString()); + stdout.WriteLine($"{{\"jsonrpc\":\"2.0\",\"id\":\"{req.RootElement.GetProperty("id").GetString()}\",\"result\":{{\"topic\":\"falcon\"}}}}"); + } + Assert.Equal("falcon", (await leave.WaitAsync(TimeSpan.FromSeconds(5))).Topic); + + var pair = client.PairAsync("alice"); + var pairLine = await stdin.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); + using (var req = JsonDocument.Parse(pairLine)) + { + Assert.Equal("session.pair", req.RootElement.GetProperty("method").GetString()); + Assert.Equal("alice", req.RootElement.GetProperty("params").GetProperty("me").GetString()); + stdout.WriteLine($"{{\"jsonrpc\":\"2.0\",\"id\":\"{req.RootElement.GetProperty("id").GetString()}\",\"result\":{{\"status\":\"online\",\"me\":\"alice\",\"topics\":[\"$session\"]}}}}"); + } + Assert.Equal("alice", (await pair.WaitAsync(TimeSpan.FromSeconds(5))).Me); + stdout.Complete(); + } + [Fact] public async Task Dispose_stops_native_child() { @@ -169,7 +231,7 @@ public async Task Dispose_stops_native_child() try { var snap = await client.InitializeAsync(store).WaitAsync(TimeSpan.FromSeconds(30)); - Assert.Equal("new", snap.Status); + Assert.Equal(SessionStatus.New, snap.Status); } finally { @@ -251,7 +313,7 @@ public async Task Initialize_writes_default_device_name() stdout.WriteLine(RpcResult(id!)); var snap = await task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal("new", snap.Status); + Assert.Equal(SessionStatus.New, snap.Status); stdout.Complete(); } @@ -266,11 +328,17 @@ public async Task Initialize_fresh_store_status_new() await using var client = new InboxClient(host.StandardOutput, host.StandardInput, host, host.StandardError); var snap = await client.InitializeAsync(store).WaitAsync(TimeSpan.FromSeconds(30)); Console.WriteLine($"initialize status={snap.Status}"); - Assert.Equal("new", snap.Status); + Assert.Equal(SessionStatus.New, snap.Status); Assert.Contains("$session", snap.Topics); + Assert.Equal("whatsapp", snap.Product); + Assert.Equal(Identity.User, snap.Identity); + Assert.NotNull(snap.Capabilities); + Assert.Equal(MeBinding.Issued, snap.Capabilities.Me); + Assert.Equal(MembershipCapability.None, snap.Capabilities.Membership); + Assert.Contains(AuthKind.Qr, snap.Capabilities.Auth); var status = await client.StatusAsync().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal("new", status.Status); + Assert.Equal(SessionStatus.New, status.Status); } finally { diff --git a/src/Tests/InboxJsonContextTests.cs b/src/Tests/InboxJsonContextTests.cs index 8ba94d7..17182d2 100644 --- a/src/Tests/InboxJsonContextTests.cs +++ b/src/Tests/InboxJsonContextTests.cs @@ -39,6 +39,20 @@ public void Request_with_params_uses_context() Assert.False(p.TryGetProperty("files", out _)); Assert.Equal(InitializeOptions.DefaultDeviceName, p.GetProperty("deviceName").GetString()); Assert.StartsWith("whatsbox on ", p.GetProperty("deviceName").GetString()); + Assert.False(p.TryGetProperty("me", out _)); + } + + [Fact] + public void InitializeOptions_sends_claimed_me_when_set() + { + var line = JsonRpc.Request( + "9", + "initialize", + new InitializeOptions { Store = @"D:\data\box", Me = "alice" }, + JsonRpcContext.Default.JsonRpcRequestInitializeOptions); + + using var doc = JsonDocument.Parse(line); + Assert.Equal("alice", doc.RootElement.GetProperty("params").GetProperty("me").GetString()); } [Fact] @@ -57,22 +71,49 @@ public void InitializeOptions_sends_custom_device_name() [Fact] public void SessionSnapshot_round_trips_through_context() { - var json = """{"status":"new","topics":["$session"],"version":"0.1","product":"whatsapp","identity":"user","capabilities":{"auth":["qr"],"reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"}}"""; + var json = """{"status":"new","topics":["$session"],"version":"0.1","product":"whatsapp","identity":"user","capabilities":{"auth":["qr"],"me":"issued","membership":"none","reply":"quote","react":true,"read":"message","ack":true,"files":true,"attachments":"single"}}"""; var snap = JsonSerializer.Deserialize(json, InboxJsonContext.Default.SessionSnapshot); Assert.NotNull(snap); - Assert.Equal("new", snap.Status); + Assert.Equal(SessionStatus.New, snap.Status); Assert.Equal(["$session"], snap.Topics); Assert.Equal("0.1", snap.Version); Assert.Equal("whatsapp", snap.Product); - Assert.Equal("user", snap.Identity); + Assert.Equal(Identity.User, snap.Identity); Assert.NotNull(snap.Capabilities); - Assert.Equal("quote", snap.Capabilities.Reply); - Assert.Equal("single", snap.Capabilities.Attachments); + Assert.Equal(MeBinding.Issued, snap.Capabilities.Me); + Assert.Equal(MembershipCapability.None, snap.Capabilities.Membership); + Assert.Equal(ReplyCapability.Quote, snap.Capabilities.Reply); + Assert.Equal(ReadCapability.Message, snap.Capabilities.Read); + Assert.Equal(AttachmentsCapability.Single, snap.Capabilities.Attachments); + Assert.Equal([AuthKind.Qr], snap.Capabilities.Auth); var back = JsonSerializer.Serialize(snap, InboxJsonContext.Default.SessionSnapshot); using var doc = JsonDocument.Parse(back); Assert.Equal("new", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal("0.1", doc.RootElement.GetProperty("version").GetString()); + Assert.Equal("whatsapp", doc.RootElement.GetProperty("product").GetString()); + Assert.Equal("user", doc.RootElement.GetProperty("identity").GetString()); Assert.False(doc.RootElement.TryGetProperty("self", out _)); + var caps = doc.RootElement.GetProperty("capabilities"); + Assert.Equal("issued", caps.GetProperty("me").GetString()); + Assert.Equal("none", caps.GetProperty("membership").GetString()); + Assert.Equal("quote", caps.GetProperty("reply").GetString()); + Assert.Equal("message", caps.GetProperty("read").GetString()); + Assert.Equal("single", caps.GetProperty("attachments").GetString()); + Assert.Equal("qr", Assert.Single(caps.GetProperty("auth").EnumerateArray()).GetString()); + } + + [Fact] + public void AuthKind_device_code_stays_snake_case_on_the_wire() + { + var caps = new Capabilities { Auth = [AuthKind.DeviceCode, AuthKind.Token] }; + var json = JsonSerializer.Serialize(caps, InboxJsonContext.Default.Capabilities); + using var doc = JsonDocument.Parse(json); + var auth = doc.RootElement.GetProperty("auth").EnumerateArray().Select(e => e.GetString()!).ToArray(); + Assert.Equal(["device_code", "token"], auth); + + var round = JsonSerializer.Deserialize(json, InboxJsonContext.Default.Capabilities); + Assert.Equal([AuthKind.DeviceCode, AuthKind.Token], round!.Auth); } } diff --git a/src/Tests/InboxProtocolSpecTests.cs b/src/Tests/InboxProtocolSpecTests.cs index 1c64e77..63495ea 100644 --- a/src/Tests/InboxProtocolSpecTests.cs +++ b/src/Tests/InboxProtocolSpecTests.cs @@ -76,6 +76,10 @@ public void Common_methods_are_whatsbox_v1_nouns() "unsubscribe", "directory.list", "directory.get", + "directory.find", + "directory.join", + "directory.leave", + "directory.create", "messages.send", "messages.read", ]; @@ -143,7 +147,11 @@ public void Maps_discord_slack_teams_telegram_matrix() Assert.Contains("### 18.1 Bot API", spec, StringComparison.Ordinal); Assert.Contains("### 18.2 MTProto user client", spec, StringComparison.Ordinal); Assert.Contains("## 19. Matrix mapping", spec, StringComparison.Ordinal); + Assert.Contains("## 20. Azure Web PubSub mapping", spec, StringComparison.Ordinal); Assert.Contains("WhatsApp reference profile", spec, StringComparison.Ordinal); + Assert.Contains("`webpubsub`", spec, StringComparison.Ordinal); + Assert.Contains("\"claimed\"", spec, StringComparison.Ordinal); + Assert.Contains("\"create\"", spec, StringComparison.Ordinal); Assert.Contains("self-bot", spec, StringComparison.OrdinalIgnoreCase); Assert.Contains("Bot only", spec, StringComparison.Ordinal); @@ -159,7 +167,7 @@ public void Maps_discord_slack_teams_telegram_matrix() public void Capability_matrix_covers_auth_reply_react_read_files_difficulty() { var matrix = MatrixSection(); - foreach (var product in new[] { "WhatsApp", "Discord", "Slack", "Teams", "Telegram Bot", "Telegram user", "Matrix" }) + foreach (var product in new[] { "WhatsApp", "Discord", "Slack", "Teams", "Telegram Bot", "Telegram user", "Matrix", "Web PubSub" }) Assert.Contains(product, matrix, StringComparison.Ordinal); foreach (var header in new[] { "Auth", "Live path", "Reply", "Reactions", "Mark-read", "Files", "Diff" }) @@ -187,7 +195,7 @@ public void Gaps_have_degraded_behavior_not_silent_ignore() Assert.Contains("`reply: \"context\"`", spec, StringComparison.Ordinal); Assert.Contains("`reply: \"quote\"`", spec, StringComparison.Ordinal); Assert.Contains("**not** aliases", spec, StringComparison.Ordinal); - Assert.Contains("MUST NOT put JSON `true` or `false` on `reply`, `read`, or `attachments`", spec, StringComparison.Ordinal); + Assert.Contains("MUST NOT put JSON `true` or `false` on `reply`, `read`, `attachments`, `me`, or `membership`", spec, StringComparison.Ordinal); Assert.Contains("`false` is not `\"none\"`", spec, StringComparison.Ordinal); Assert.Contains("`attachments: \"single\"`", spec, StringComparison.Ordinal); Assert.Contains("capability: \"attachments\"", spec, StringComparison.Ordinal); @@ -201,6 +209,10 @@ public void Gaps_have_degraded_behavior_not_silent_ignore() Assert.Contains("identity: \"bot\"", spec, StringComparison.Ordinal); Assert.Contains("### 2.5 Hosted ingress", spec, StringComparison.Ordinal); Assert.Contains("no** `webhook.register` method", spec, StringComparison.Ordinal); + Assert.Contains("`me_required`", spec, StringComparison.Ordinal); + Assert.Contains("`topic_taken`", spec, StringComparison.Ordinal); + Assert.Contains("`capabilities.me`", spec, StringComparison.Ordinal); + Assert.Contains("`capabilities.membership`", spec, StringComparison.Ordinal); } [Fact] @@ -260,7 +272,7 @@ public void Context_is_opaque_grouping_not_a_topic() Assert.Contains("Group by `context`", spec, StringComparison.Ordinal); Assert.Contains("Discord **threads are channels**", spec, StringComparison.Ordinal); Assert.DoesNotContain("`reply: \"thread\"`", spec, StringComparison.Ordinal); - Assert.Contains("### 21.7 Stricter agnostic Reply", spec, StringComparison.Ordinal); + Assert.Contains("### 22.7 Stricter agnostic Reply", spec, StringComparison.Ordinal); Assert.Contains("zero** capability branch", spec, StringComparison.Ordinal); } @@ -275,9 +287,9 @@ string IndexSection() string MatrixSection() { - var start = spec.IndexOf("## 20. Capability and difficulty matrix", StringComparison.Ordinal); + var start = spec.IndexOf("## 21. Capability and difficulty matrix", StringComparison.Ordinal); Assert.True(start >= 0, "missing capability matrix section"); - var end = spec.IndexOf("\n## 21.", start, StringComparison.Ordinal); + var end = spec.IndexOf("\n## 22.", start, StringComparison.Ordinal); Assert.True(end > start, "matrix not bounded"); return spec[start..end]; } diff --git a/src/Tests/ProjectIdentityDocsTests.cs b/src/Tests/ProjectIdentityDocsTests.cs index e4f6121..c43df25 100644 --- a/src/Tests/ProjectIdentityDocsTests.cs +++ b/src/Tests/ProjectIdentityDocsTests.cs @@ -134,6 +134,23 @@ public void WhatsBox_profile_is_the_adapter_not_this_repository() Assert.Contains("whatsbox [--store ABSOLUTE_PATH]", text, StringComparison.Ordinal); } + [Fact] + public void PubSubBox_profile_is_the_adapter_not_the_protocol() + { + var text = File.ReadAllText(Path.Combine(Repo, "docs", "PUBSUBBOX.md")); + var firstRule = text.IndexOf("---", StringComparison.Ordinal); + Assert.True(firstRule > 0, "PUBSUBBOX.md intro missing horizontal rule"); + var intro = text[..firstRule]; + + Assert.Contains("Azure Web PubSub Chat adapter", intro, StringComparison.Ordinal); + Assert.Contains("Inbox Client Protocol (ICP)", intro, StringComparison.Ordinal); + Assert.Contains("`pubsubbox`", intro, StringComparison.Ordinal); + Assert.DoesNotContain("grokbox.local", intro, StringComparison.Ordinal); + Assert.Contains("content.binary", text, StringComparison.Ordinal); + Assert.Contains("x-ms-meta-text", text, StringComparison.Ordinal); + Assert.Contains("claimed", text, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Osmf_software_name_is_Inbox() { diff --git a/src/WhatsBox.Native/app/daemon.go b/src/WhatsBox.Native/app/daemon.go index 12ca50d..0f6d624 100644 --- a/src/WhatsBox.Native/app/daemon.go +++ b/src/WhatsBox.Native/app/daemon.go @@ -176,7 +176,7 @@ func (d *Daemon) dispatch(ctx context.Context, req *rpc.Request) (any, *rpc.Erro case "session.connect": return d.sessionConnect(ctx) case "session.pair": - return d.sessionPair(ctx) + return d.sessionPair(ctx, req.Params) case "session.disconnect": return d.sessionDisconnect() case "session.logout": @@ -191,6 +191,8 @@ func (d *Daemon) dispatch(ctx context.Context, req *rpc.Request) (any, *rpc.Erro return d.directoryList(req.Params) case "directory.get": return d.directoryGet(ctx, req.Params) + case "directory.find", "directory.join", "directory.leave", "directory.create": + return nil, errUnsupported("membership") case "messages.send": return d.messagesSend(ctx, req.Params) case "messages.read": @@ -208,6 +210,7 @@ type initParams struct { Verbosity string `json:"verbosity"` Connect *bool `json:"connect"` DeviceName string `json:"deviceName"` + Me string `json:"me"` } func (d *Daemon) initialize(ctx context.Context, raw json.RawMessage) (any, *rpc.Error) { @@ -228,6 +231,9 @@ func (d *Daemon) initialize(ctx context.Context, raw json.RawMessage) (any, *rpc if p.Version != rpc.Version { return nil, rpc.ErrData(rpc.TokUnsupportedVersion, map[string]any{"supported": []string{rpc.Version}}) } + if strings.TrimSpace(p.Me) != "" { + return nil, rpc.ErrData(rpc.TokInvalidParams, "me") + } store, err := d.resolveStore(p.Store) if err != nil { return nil, err @@ -373,6 +379,8 @@ type statusResult struct { func boxCapabilities() map[string]any { return map[string]any{ "auth": []string{"qr"}, + "me": "issued", + "membership": "none", "reply": "quote", "react": true, "read": "message", @@ -451,7 +459,16 @@ func (d *Daemon) sessionConnect(ctx context.Context) (any, *rpc.Error) { return res, nil } -func (d *Daemon) sessionPair(ctx context.Context) (any, *rpc.Error) { +func (d *Daemon) sessionPair(ctx context.Context, raw json.RawMessage) (any, *rpc.Error) { + var p struct { + Me string `json:"me"` + } + if err := rpc.DecodeParams(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Me) != "" { + return nil, rpc.ErrData(rpc.TokInvalidParams, "me") + } d.mu.Lock() cli := d.wa st := d.status diff --git a/src/WhatsBox.Native/app/protocol_test.go b/src/WhatsBox.Native/app/protocol_test.go index 437ba11..4a8b104 100644 --- a/src/WhatsBox.Native/app/protocol_test.go +++ b/src/WhatsBox.Native/app/protocol_test.go @@ -350,7 +350,7 @@ func TestNotInitializedAndAlreadyInitialized(t *testing.T) { t.Fatalf("product/identity=%v %v", res["product"], res["identity"]) } caps, _ := res["capabilities"].(map[string]any) - if caps["reply"] != "quote" || caps["read"] != "message" || caps["attachments"] != "single" { + if caps["reply"] != "quote" || caps["read"] != "message" || caps["attachments"] != "single" || caps["me"] != "issued" || caps["membership"] != "none" { t.Fatalf("capabilities=%v", caps) } topics := asStrings(res["topics"]) @@ -366,6 +366,30 @@ func TestNotInitializedAndAlreadyInitialized(t *testing.T) { } } +func TestIssuedMeRejectsClaimedMeAndMembershipRpcs(t *testing.T) { + fake := wa.NewFake() + c := startDaemon(t, fake, 0) + _, err := c.call("initialize", map[string]any{"version": "0.1", "store": c.store, "me": "alice"}) + if err == nil || err.Message != rpc.TokInvalidParams { + t.Fatalf("initialize me: %#v", err) + } + c.mustInit(map[string]any{"connect": false}) + _, err = c.call("session.pair", map[string]any{"me": "alice"}) + if err == nil || err.Message != rpc.TokInvalidParams { + t.Fatalf("pair me: %#v", err) + } + for _, method := range []string{"directory.find", "directory.join", "directory.leave", "directory.create"} { + _, err = c.call(method, map[string]any{"query": "x", "id": "x", "name": "x"}) + if err == nil || err.Message != rpc.TokUnsupported { + t.Fatalf("%s: %#v", method, err) + } + data, _ := err.Data.(map[string]any) + if data["capability"] != "membership" { + t.Fatalf("%s data=%v", method, err.Data) + } + } +} + func TestUnsupportedVersionAndStoreRules(t *testing.T) { fake := wa.NewFake() c := startDaemon(t, fake, 0) diff --git a/src/WhatsBox.Native/rpc/rpc.go b/src/WhatsBox.Native/rpc/rpc.go index 9897fca..901bdb9 100644 --- a/src/WhatsBox.Native/rpc/rpc.go +++ b/src/WhatsBox.Native/rpc/rpc.go @@ -25,6 +25,8 @@ const ( CodeInvalidParams = -32013 CodeDisconnected = -32014 CodeUnsupported = -32015 + CodeMeRequired = -32016 + CodeTopicTaken = -32017 CodeParseError = -32700 CodeInvalidRequest = -32600 CodeMethodNotFound = -32601 @@ -46,6 +48,8 @@ const ( TokInvalidParams = "invalid_params" TokDisconnected = "disconnected" TokUnsupported = "unsupported" + TokMeRequired = "me_required" + TokTopicTaken = "topic_taken" TokParseError = "parse_error" TokInvalidRequest = "invalid_request" TokMethodNotFound = "method_not_found" @@ -108,6 +112,8 @@ var tokenCode = map[string]int{ TokInvalidParams: CodeInvalidParams, TokDisconnected: CodeDisconnected, TokUnsupported: CodeUnsupported, + TokMeRequired: CodeMeRequired, + TokTopicTaken: CodeTopicTaken, TokParseError: CodeParseError, TokInvalidRequest: CodeInvalidRequest, TokMethodNotFound: CodeMethodNotFound, diff --git a/src/WhatsDemo.Tests/AtMentionsTests.cs b/src/WhatsDemo.Tests/AtMentionsTests.cs index 72226d4..99a380c 100644 --- a/src/WhatsDemo.Tests/AtMentionsTests.cs +++ b/src/WhatsDemo.Tests/AtMentionsTests.cs @@ -92,7 +92,7 @@ public void Complete_lists_subscribed_groups_by_subject_without_at() book.Remember(new DirectoryRow { Topic = "5491159278282-1472673286@g.us", - Kind = "group", + Kind = DirectoryKind.Group, Name = "Nosotros", }); var session = new DemoSession(book); diff --git a/src/WhatsDemo.Tests/DirectoryBookTests.cs b/src/WhatsDemo.Tests/DirectoryBookTests.cs index 54c3fe5..a0a4434 100644 --- a/src/WhatsDemo.Tests/DirectoryBookTests.cs +++ b/src/WhatsDemo.Tests/DirectoryBookTests.cs @@ -46,7 +46,7 @@ public void Remember_row_indexes_topic_pn_and_handle_but_snapshots_one_canonical book.Remember(new DirectoryRow { Topic = "111@lid", - Kind = "user", + Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada", Pn = "5491156103511@s.whatsapp.net", @@ -73,7 +73,7 @@ public void Author_events_do_not_rename_a_group() book.Remember(new DirectoryRow { Topic = "5491159278282-1472673286@g.us", - Kind = "group", + Kind = DirectoryKind.Group, Name = "Nosotros", }); book.Remember("5491159278282-1472673286@g.us", "@agus", "agus"); @@ -89,7 +89,7 @@ public void Remember_row_indexes_group_participants_separately() book.Remember(new DirectoryRow { Topic = "12036342@g.us", - Kind = "group", + Kind = DirectoryKind.Group, Name = "Family", Participants = [ @@ -140,7 +140,7 @@ public void Directory_get_reclaims_pn_from_a_wrong_lid() book.Remember(new DirectoryRow { Topic = "111@lid", - Kind = "user", + Kind = DirectoryKind.User, Name = "Any", Pn = "5491156103511@s.whatsapp.net", }); diff --git a/src/WhatsDemo.Tests/DirectorySyncTests.cs b/src/WhatsDemo.Tests/DirectorySyncTests.cs index c4ca683..b71c296 100644 --- a/src/WhatsDemo.Tests/DirectorySyncTests.cs +++ b/src/WhatsDemo.Tests/DirectorySyncTests.cs @@ -19,7 +19,7 @@ public async Task Warm_fetches_subscriptions_and_persists_aliases() return Task.FromResult(new DirectoryRow { Topic = id, - Kind = "user", + Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada", }); @@ -55,7 +55,7 @@ public async Task Subscribe_persists_canonical_topics_and_fetches_them() return Task.FromResult(new DirectoryRow { Topic = "111@lid", - Kind = "user", + Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada", Pn = "+15551234567", @@ -99,7 +99,7 @@ public async Task Subscribe_merges_into_existing_subscriptions() Task.FromResult(new DirectoryRow { Topic = id.Contains("111", StringComparison.Ordinal) ? "111@lid" : id, - Kind = "user", + Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada", })); @@ -131,7 +131,7 @@ public async Task Unsubscribe_keeps_aliases_and_still_resolves_the_requested_id( return Task.FromResult(new DirectoryRow { Topic = id, - Kind = "user", + Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada", }); @@ -164,7 +164,7 @@ public async Task Resolve_skips_when_cached_unless_forced() var sync = new DirectorySync(book, path, ["111@lid"], (_, _) => { got++; - return Task.FromResult(new DirectoryRow { Topic = "111@lid", Kind = "user", Name = "Ada" }); + return Task.FromResult(new DirectoryRow { Topic = "111@lid", Kind = DirectoryKind.User, Name = "Ada" }); }); await sync.ResolveAsync("111@lid"); @@ -196,7 +196,7 @@ public void Load_hydrates_book_from_existing_toml() var sync = DirectorySync.Load(book, dir, (_, _) => Task.FromResult(new DirectoryRow { Topic = "111@lid", - Kind = "user", + Kind = DirectoryKind.User, })); Assert.Equal(["111@lid"], sync.InitialSubscribe); diff --git a/src/WhatsDemo.Tests/JsonPanelTests.cs b/src/WhatsDemo.Tests/JsonPanelTests.cs index 6d4df9b..2ec617a 100644 --- a/src/WhatsDemo.Tests/JsonPanelTests.cs +++ b/src/WhatsDemo.Tests/JsonPanelTests.cs @@ -30,7 +30,7 @@ public void Topics_result_is_indented_json_inside_a_box() public void Directory_row_omits_nulls_and_keeps_camel_case() { var panel = JsonPanel.Render( - new DirectoryRow { Topic = "111@lid", Kind = "user", Name = "Ada" }, + new DirectoryRow { Topic = "111@lid", Kind = DirectoryKind.User, Name = "Ada" }, InboxJsonContext.Default.DirectoryRow); Assert.Contains("\"topic\": \"111@lid\"", panel); @@ -44,7 +44,7 @@ public void Directory_row_omits_nulls_and_keeps_camel_case() public void Renders_spanish_accents_as_unicode_not_escapes() { var panel = JsonPanel.Render( - new DirectoryRow { Topic = "111@lid", Kind = "user", Name = "Analía Carvallo" }, + new DirectoryRow { Topic = "111@lid", Kind = DirectoryKind.User, Name = "Analía Carvallo" }, InboxJsonContext.Default.DirectoryRow); Assert.Contains("Analía Carvallo", panel); diff --git a/src/WhatsDemo.Tests/SlashCommandsTests.cs b/src/WhatsDemo.Tests/SlashCommandsTests.cs index bc8f77e..9737813 100644 --- a/src/WhatsDemo.Tests/SlashCommandsTests.cs +++ b/src/WhatsDemo.Tests/SlashCommandsTests.cs @@ -1,3 +1,5 @@ +using Inbox; + namespace WhatsDemo.Tests; public class SlashCommandsTests @@ -127,7 +129,7 @@ public void Unsubscribe_lists_subscribed_chats_except_self() book.Remember(new Inbox.DirectoryRow { Topic = "12036342@g.us", - Kind = "group", + Kind = DirectoryKind.Group, Name = "Nosotros", }); var session = new DemoSession(book); @@ -153,7 +155,7 @@ public void Unsubscribe_filters_by_argument_prefix() book.Remember(new Inbox.DirectoryRow { Topic = "12036342@g.us", - Kind = "group", + Kind = DirectoryKind.Group, Name = "Nosotros", }); var session = new DemoSession(book); diff --git a/src/WhatsDemo.Tests/TopicResolverTests.cs b/src/WhatsDemo.Tests/TopicResolverTests.cs index d2cc686..d500433 100644 --- a/src/WhatsDemo.Tests/TopicResolverTests.cs +++ b/src/WhatsDemo.Tests/TopicResolverTests.cs @@ -59,7 +59,7 @@ public async Task Resolve_auto_subscribes_a_unique_directory_hit() Assert.Equal("Nosotros", q); return Task.FromResult>( [ - new() { Topic = "12036399@g.us", Kind = "group", Name = "Nosotros" }, + new() { Topic = "12036399@g.us", Kind = DirectoryKind.Group, Name = "Nosotros" }, ]); }, (_, _) => throw new InvalidOperationException("unique hit must not pick")); @@ -85,8 +85,8 @@ public async Task Resolve_picks_when_directory_list_is_ambiguous() { DirectoryRow[] rows = [ - new() { Topic = "111@lid", Kind = "user", Name = "Ana", Handle = "@ana" }, - new() { Topic = "222@lid", Kind = "user", Name = "Analía" }, + new() { Topic = "111@lid", Kind = DirectoryKind.User, Name = "Ana", Handle = "@ana" }, + new() { Topic = "222@lid", Kind = DirectoryKind.User, Name = "Analía" }, ]; var result = await TopicResolver.ResolveAsync( @@ -111,8 +111,8 @@ public async Task Resolve_cancelled_when_the_picker_returns_empty() "Ana", (_, _) => Task.FromResult>( [ - new() { Topic = "111@lid", Kind = "user", Name = "Ana" }, - new() { Topic = "222@lid", Kind = "user", Name = "Analía" }, + new() { Topic = "111@lid", Kind = DirectoryKind.User, Name = "Ana" }, + new() { Topic = "222@lid", Kind = DirectoryKind.User, Name = "Analía" }, ]), (_, _) => Task.FromResult(null)); @@ -125,8 +125,8 @@ public void Completions_insert_the_canonical_topic() { var items = TopicResolver.Completions( [ - new() { Topic = "12036399@g.us", Kind = "group", Name = "Nosotros" }, - new() { Topic = "999@lid", Kind = "user", Handle = "@ada", Name = "Ada" }, + new() { Topic = "12036399@g.us", Kind = DirectoryKind.Group, Name = "Nosotros" }, + new() { Topic = "999@lid", Kind = DirectoryKind.User, Handle = "@ada", Name = "Ada" }, ]); Assert.Equal("12036399@g.us", items[0].Insert); diff --git a/src/WhatsDemo/Program.cs b/src/WhatsDemo/Program.cs index bd60807..f8f5c50 100644 --- a/src/WhatsDemo/Program.cs +++ b/src/WhatsDemo/Program.cs @@ -247,13 +247,13 @@ static async Task DispatchAsync( { var snap = await box.LogoutAsync(cancellation); session.ClearIdentity(); - output.WriteLine($"status {snap.Status}"); + output.WriteLine($"status {snap.Status.ToString().ToLowerInvariant()}"); break; } case "disconnect": { var snap = await box.DisconnectAsync(cancellation); - output.WriteLine($"status {snap.Status}"); + output.WriteLine($"status {snap.Status.ToString().ToLowerInvariant()}"); break; } case "connect": @@ -261,7 +261,7 @@ static async Task DispatchAsync( var snap = await box.ConnectAsync(cancellation); session.NoteIdentity(snap.Me); sync.NoteSelf(snap.Me); - output.WriteLine($"status {snap.Status}"); + output.WriteLine($"status {snap.Status.ToString().ToLowerInvariant()}"); await EnsureSubscribedAsync(box, sync, snap.Me, cancellation); break; } From db37106ce3c3b9a0c4f41cad2d5a95f607a2ba49 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sun, 23 Aug 2026 22:59:34 -0300 Subject: [PATCH 2/2] fix: address CI failure in build-ubuntu-latest --- src/Tests/ProjectIdentityDocsTests.cs | 4 ++-- src/Tests/WhatsBoxPackTests.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Tests/ProjectIdentityDocsTests.cs b/src/Tests/ProjectIdentityDocsTests.cs index c43df25..f719fca 100644 --- a/src/Tests/ProjectIdentityDocsTests.cs +++ b/src/Tests/ProjectIdentityDocsTests.cs @@ -229,11 +229,11 @@ static string FindRepoRoot() var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { - if (File.Exists(Path.Combine(dir.FullName, "WhatsBox.slnx"))) + if (File.Exists(Path.Combine(dir.FullName, "Inbox.slnx"))) return dir.FullName; dir = dir.Parent; } - throw new InvalidOperationException("Could not find WhatsBox.slnx from " + AppContext.BaseDirectory); + throw new InvalidOperationException("Could not find Inbox.slnx from " + AppContext.BaseDirectory); } } diff --git a/src/Tests/WhatsBoxPackTests.cs b/src/Tests/WhatsBoxPackTests.cs index d7ffd6e..497eed5 100644 --- a/src/Tests/WhatsBoxPackTests.cs +++ b/src/Tests/WhatsBoxPackTests.cs @@ -95,7 +95,7 @@ public void WriteInboxRuntimeJson_maps_six_rids_to_rid_packages() public void Pointer_and_rid_csproj_use_calc_pack_split() { var repo = FindRepoRoot(); - var slnx = File.ReadAllText(Path.Combine(repo, "WhatsBox.slnx")); + var slnx = File.ReadAllText(Path.Combine(repo, "Inbox.slnx")); Assert.Contains("src/Inbox/Inbox.csproj", slnx); var inbox = File.ReadAllText(Path.Combine(repo, "src", "Inbox", "Inbox.csproj")); Assert.Contains("Inbox", inbox); @@ -232,11 +232,11 @@ static string FindRepoRoot() var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { - if (File.Exists(Path.Combine(dir.FullName, "WhatsBox.slnx"))) + if (File.Exists(Path.Combine(dir.FullName, "Inbox.slnx"))) return dir.FullName; dir = dir.Parent; } - throw new InvalidOperationException("Could not find WhatsBox.slnx from " + AppContext.BaseDirectory); + throw new InvalidOperationException("Could not find Inbox.slnx from " + AppContext.BaseDirectory); } }