Files
buzz/crates/buzz-cli/src
Blake McAnally 171100f308 feat(cli): read-only unread state (NIP-RS) (#8789)
## Summary

This change adds `buzz channels unread`. The command is read-only. It
shows how many unread messages each channel has. It uses the read state
that desktop and mobile already sync (NIP-RS). It never publishes an
event. It never marks anything read.

Output: `[{channel_id, name, last_read_at, unread_count, capped}]`. The
most unread channel is first.

## Design

The command has two parts.

**Part 1: read state** (`commands/read_state.rs`)

- The command queries your own `kind:30078` events with the `read-state`
tag.
- It verifies each signature. It rejects events from other authors.
- It keeps the newest version of each `d` tag.
- It decrypts each slot with NIP-44 to yourself. It applies desktop's
validation rules.
- It merges all slots with `max`.
- It ignores `ov_*` keys. Desktop and mobile do not read them either.

**Part 2: counting** (`commands/channel_unread.rs`)

The rules follow desktop's `communityUnreadObserver.ts`.

- Without `--channel`, the command uses your non-archived channel
memberships.
- It reads message kinds 9, 40002, 45001, and 45003. For a DM it also
reads huddle starts (48100).
- It reads only events after the channel marker (`since = marker + 1`).
- Your own messages do not count.
- A message is read when its time is at or below the greatest of three
markers: the channel marker, its `msg:<id>` marker, and its
`thread:<root>` marker. A broadcast reply ignores the thread marker.

**Bounds and signals**

- At most 200 channels per run. `--limit` sets the number. The maximum
is 200.
- At most 1000 events per channel. A channel at that cap shows
`"capped": true`. Its count is a lower bound.
- If you have more channels than `--limit`, the extra channels are not
counted. Stderr shows
`{"warning":"channels_truncated","counted":N,"dropped":M}`.
- More than `--limit` `--channel` values is an error. The command never
drops a named channel.
- An unknown named channel exits with code 1.

**Failure rules**

- A slot that cannot be verified, decrypted, or decoded fails the
command with exit 4. Desktop skips such a slot. A skip would make the
count too high, with no sign of it.
- A slot with a newer `v` is skipped. Stderr shows
`read_state_slots_skipped`.

**Deviations from desktop**

- The command has no 7-day fetch horizon. It reads all slots. This can
only add markers.
- It does not apply desktop's notification gate. That gate uses
desktop-local data (followed threads, muted channels). So a count can be
higher than the desktop badge.
- It does not read mobile's `activity:` markers. Desktop does not read
them.

The README describes the output and the gotchas. The stable-inventory
test lists the new subcommand. This PR does not change `desktop/`
(deprecated; see #8840).

## Testing

**What ran**

- `cargo test -p buzz-cli`: 510 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.

**Unit tests**

The tests run the production functions against a fake axum relay. The
fake relay applies the real filters (`#h`, `#d`, `#p`, `#t`, `since`,
`limit`).

- Merge: max across slots, newest version wins, bad values dropped,
`ov_*` ignored.
- Failures: bad JSON, wrong shape, bad `client_id`, wrong author, bad
signature, and undecryptable content all fail.
- Counting: own messages, `msg:` markers, `thread:` markers, broadcast
replies, archived channels, DM kinds, and sort order.
- Bounds: channel limit, event cap, `capped`, and explicit-channel
limits.
- Query shape: each channel query has `#h`, a bounded `limit`, and
`since = marker + 1`.
- A bad slot stops the command before any channel query.

**Interop evidence**

A node script runs the real `buzz` binary against a fake relay. Events
are made and encrypted by `nostr-tools`. This is an independent NIP-44
code base. The script compares the CLI with desktop's own TypeScript:

- `mergeReadStateEvents` gives the same frontier as `last_read_at` for
six channels.
- `fetchCommunityUnread` gives the same read/unread result as
`unread_count > 0` for each channel.

The six channels cover: read, unread, never read, own message only, two
slots with max-merge, and a `msg:` marker. The script prints `INTEROP
OK`. The script is not part of the repository.

**Mutation checks**

I broke each guard once and ran the tests. Each break made a test fail:

- Own-message exclusion.
- `msg:` marker, `thread:` marker, and broadcast rule.
- `since = marker + 1`.
- `capped` signal and channel limit.
- Explicit channels over the limit.
- Archived skip and DM kinds.
- Max-merge, newest version wins, and the `t` tag requirement.
- Signature check, author check, unknown `v`, and the 256-byte key
limit.

A mutation of the decrypt error path did not fail a test. The JSON step
fails the same way, so the break changes nothing. I removed that check
from the list.

## Manual test steps

You need a relay and an identity that has used desktop or mobile on that
relay.

1. Build the CLI:

   ```bash
   . ./bin/activate-hermit && cargo build -p buzz-cli
   ```

2. Set the identity and the relay:

   ```bash
   export BUZZ_RELAY_URL=<relay-url>
   export BUZZ_PRIVATE_KEY=<your-key>
   ```

3. Run the command:

   ```bash
   ./target/debug/buzz channels unread | jq .
   ```

Success: you see one object per channel. Each object has `channel_id`,
`name`, `last_read_at`, `unread_count`, and `capped`.

4. Compare with the app. Open desktop or mobile on the same identity. A
channel with a badge must show `unread_count` above 0. A channel with no
badge must show 0. Counts can be higher than the badge for muted
channels and followed threads. This is expected.

5. Read a channel in the app. Wait a few seconds. Run the command again.
Success: the count for that channel is 0 and `last_read_at` is higher.

6. Test the channel limit:

   ```bash
   ./target/debug/buzz channels unread --limit 1 | jq length
   ```

Success: the command prints `1`. If you have more than one channel,
stderr shows `channels_truncated`.

7. Test an unknown channel:

   ```bash
./target/debug/buzz channels unread --channel
00000000-0000-4000-8000-000000000000; echo $?
   ```

Success: the command prints a not-found error and the exit code is `1`.

8. Check that nothing was written. The command sends no events. The app
badges stay the same after the run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:27:47 -06:00
..