refactor: rename sprout backend to buzz (#958)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
This commit is contained in:
Will Pfleger
2026-06-10 19:29:51 -04:00
committed by GitHub
parent 53e3f09485
commit d99ad131f1
306 changed files with 3249 additions and 3373 deletions

View File

@@ -0,0 +1,67 @@
[package]
name = "buzz-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Buzz relay"
[lib]
name = "buzz_cli"
path = "src/lib.rs"
[[bin]]
name = "buzz"
path = "src/main.rs"
[dependencies]
# CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired)
clap = { version = "4", features = ["derive", "env"] }
# HTTP client — async REST calls to the relay
reqwest = { workspace = true, features = ["json"] }
# Async runtime — tokio macros + multi-thread for reqwest
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
# Serialization — JSON body building and response passthrough
serde = { workspace = true }
serde_json = { workspace = true }
# Structured error types with exit code mapping
thiserror = { workspace = true }
# Nostr event signing — used in `buzz auth`, auto-mint, and signed event writes
nostr = { workspace = true }
# UUID parsing for validate_uuid + event building
uuid = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash
sha2 = "0.11"
# Unified-diff parser and strict applier — `mem patch`
diffy = "0.5"
# Hex encoding — SHA-256 hash output for Blossom uploads
hex = { workspace = true }
# MIME type detection via magic bytes — file upload validation
infer = "0.19"
# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
# Persona pack parsing, validation, and resolution
buzz-persona = { path = "../buzz-persona" }
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }

154
crates/buzz-cli/README.md Normal file
View File

@@ -0,0 +1,154 @@
# Buzz CLI
Agent-first command-line interface for Buzz relay. JSON in, JSON out.
## Install
```bash
cargo install --path crates/buzz-cli
```
## Authentication
| Env Var | Mode | Use Case |
|---------|------|----------|
| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
```bash
# Private key identity (NIP-98 signed requests)
export BUZZ_PRIVATE_KEY="nsec1..."
buzz channels list
```
## Usage
All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other.
```bash
# Set relay URL (defaults to http://localhost:3000)
export BUZZ_RELAY_URL="https://relay.example.com"
# Messages
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages search --query "architecture"
buzz messages edit --event <event-id> --content "Updated text"
buzz messages delete --event <event-id>
# Diffs
buzz messages send-diff --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
# Channels
buzz channels list
buzz channels create --name "my-channel" --type stream --visibility open
buzz channels join --channel <uuid>
buzz channels topic --channel <uuid> --topic "New topic"
# Reactions
buzz reactions add --event <event-id> --emoji "👍"
buzz reactions get --event <event-id>
# Users & Presence
buzz users get # your own profile
buzz users get --pubkey <hex> # single user
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
buzz users set-presence --status online
# DMs
buzz dms open --pubkey <hex>
buzz dms list
# Workflows
buzz workflows list --channel <uuid>
buzz workflows trigger --workflow <uuid>
buzz workflows approve --token <uuid>
buzz workflows approve --token <uuid> --approved false --note "needs revision"
# Forum
buzz messages vote --event <event-id> --direction up
# Canvas
buzz canvas get --channel <uuid>
buzz canvas set --channel <uuid> --content "# Welcome"
# Pipe to jq
buzz channels list | jq '.[].name'
```
## 54 Subcommands across 12 Groups
| Group | Subcommand | Description |
|-------|-----------|-------------|
| `messages` | `send` | Send a message to a channel |
| | `send-diff` | Send a code diff with metadata |
| | `edit` | Edit a message you sent |
| | `delete` | Delete a message |
| | `get` | List messages in a channel |
| | `thread` | Get a message thread |
| | `search` | Full-text search |
| | `vote` | Vote on a forum post |
| `channels` | `list` | List channels |
| | `get` | Get channel details |
| | `create` | Create a channel |
| | `update` | Update channel name/description |
| | `topic` | Set channel topic |
| | `purpose` | Set channel purpose |
| | `join` | Join a channel |
| | `leave` | Leave a channel |
| | `archive` | Archive a channel |
| | `unarchive` | Unarchive a channel |
| | `delete` | Delete a channel |
| | `members` | List channel members |
| | `add-member` | Add a member |
| | `remove-member` | Remove a member |
| `canvas` | `get` | Get channel canvas |
| | `set` | Set channel canvas |
| `reactions` | `add` | React to a message |
| | `remove` | Remove a reaction |
| | `get` | List reactions |
| `dms` | `list` | List DM conversations |
| | `open` | Open a DM (1–8 pubkeys) |
| | `add-member` | Add member to DM group |
| `users` | `get` | Get user profile(s) |
| | `set-profile` | Update your profile |
| | `presence` | Get presence status |
| | `set-presence` | Set presence status |
| `workflows` | `list` | List workflows |
| | `get` | Get workflow definition |
| | `create` | Create a workflow |
| | `update` | Update a workflow |
| | `delete` | Delete a workflow |
| | `trigger` | Trigger a workflow |
| | `runs` | Get workflow run history |
| | `approve` | Approve/deny a workflow step |
| `feed` | `get` | Get your activity feed |
| `social` | `publish` | Publish a NIP-01 note |
| | `set-contacts` | Set NIP-02 contact list |
| | `event` | Get a Nostr event |
| | `notes` | Get notes for a user |
| | `contacts` | Get NIP-02 contact list |
| `repos` | `create` | Announce a git repository (NIP-34) |
| | `get` | Get a repository announcement |
| | `list` | List repository announcements |
| `upload` | `file` | Upload a file to the Blossom store |
| `pack` | `validate` | Validate a persona pack (local, no relay) |
| | `inspect` | Inspect a persona pack (local, no relay) |
## Architecture
```
buzz <group> <subcommand> [flags]
│
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API
│ (clap) (handlers) (reqwest)
│
├─ validate.rs (UUID, hex, content size, percent-encode)
└─ error.rs (CliError → JSON stderr + exit code)
stdout: raw relay JSON
stderr: {"error": "category", "message": "detail"}
exit: 0=ok 1=user 2=network 3=auth 4=other
```

606
crates/buzz-cli/TESTING.md Normal file
View File

@@ -0,0 +1,606 @@
# buzz-cli Live Testing Guide
Manual testing runbook for verifying every CLI command against a local relay.
An agent or developer follows this step by step, running each command and
checking the output.
---
## 1. Prerequisites
Docker services running and healthy:
```bash
docker compose ps
# buzz-postgres healthy
# buzz-redis healthy
# buzz-typesense healthy
```
If not running: `just setup` from the repo root.
Tools: `jq`, `curl`, Rust toolchain.
---
## 2. Build the CLI
```bash
cargo build -p buzz-cli
```
Use `cargo run -p buzz-cli --` or the built binary at `target/debug/buzz`.
---
## 3. Start the Relay
In a separate terminal:
```bash
cd REPOS/buzz-nostr
set -a && source .env && set +a
cargo run -p buzz-relay
```
Verify:
```bash
curl -s http://localhost:3000/_liveness
# "ok" or 200 status
```
The `.env` should have `BUZZ_REQUIRE_AUTH_TOKEN=false` for local dev.
---
## 4. Mint Test Credentials
### Option A: buzz-admin (full scopes including admin)
This mints a token with all CLI-relevant scopes (including `admin:channels`)
via direct DB access. Use this for testing admin operations (archive,
delete-channel, add/remove-channel-member).
```bash
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
cargo run -p buzz-admin -- mint-token \
--name "cli-test" \
--scopes "messages:read,messages:write,channels:read,channels:write,users:read,users:write,files:read,files:write,admin:channels"
```
This generates a keypair and prints:
- **Private key (nsec)** — save for `BUZZ_PRIVATE_KEY` testing
Export:
```bash
export BUZZ_RELAY_URL="http://localhost:3000"
export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output
```
### Scope reference
| Scope | Self-mintable | Needed for |
|-------|:---:|------------|
| `messages:read` | ✅ | `messages get`, `messages thread`, `messages search`, `feed get` |
| `messages:write` | ✅ | `messages send`, `messages edit`, `messages delete`, `reactions`, `messages vote` |
| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` |
| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` |
| `users:read` | ✅ | `users get`, `users presence` |
| `users:write` | ✅ | `users set-profile`, `users set-presence` |
| `files:read` | ✅ | — |
| `files:write` | ✅ | — |
| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` |
---
## 5. Unit Tests
```bash
cargo test -p buzz-cli
# Expected: see cargo test -p buzz-cli for current count
cargo clippy -p buzz-cli -- -D warnings
# Expected: zero warnings
```
---
## 6. Live Testing — Command by Command
Run each command, verify exit code 0 and check output. Most commands
return JSON (pipe through `jq .` to validate). Commands are ordered so
earlier ones create resources that later ones need.
### 6.1 Channels
```bash
# channels create (stream)
buzz channels create --name "test-stream" --type stream --visibility open \
--description "CLI test channel" | jq .
# Save the channel ID:
CHANNEL_ID=$(buzz channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}
# channels create (forum) — needed for messages vote later
FORUM_ID=$(buzz channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')
# channels list
buzz channels list | jq .
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
buzz channels list --visibility open | jq .
buzz channels list --member | jq .
# channels get
buzz channels get --channel "$CHANNEL_ID" | jq .
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null
# channels update
buzz channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels topic
buzz channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels purpose
buzz channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels join (may already be a member from create)
buzz channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels leave
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
# sole owner (which it is after channels create). To test leave successfully,
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
buzz channels leave --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)
# Re-join so we can send messages
buzz channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels archive (requires admin:channels scope)
buzz channels archive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels unarchive
buzz channels unarchive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
```
### 6.2 Canvas
```bash
# canvas set
buzz canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
# canvas set from stdin
echo "# Canvas from stdin" | buzz canvas set --channel "$CHANNEL_ID" --content - | jq .
# canvas get
buzz canvas get --channel "$CHANNEL_ID"
# Expected: raw markdown string, or: null
```
### 6.3 Messages
```bash
# messages send
MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
echo "$MSG"
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')
# messages send with reply + broadcast
REPLY=$(buzz messages send --channel "$CHANNEL_ID" --content "Reply" \
--reply-to "$EVENT_ID" --broadcast | jq .)
echo "$REPLY"
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')
# messages send with mentions — @name in content is auto-resolved, no flag needed
buzz messages send --channel "$CHANNEL_ID" --content "Hey @someone" | jq .
# messages send with NIP-27 nostr:npub1… inline mention — auto-resolved to p-tag
buzz messages send --channel "$CHANNEL_ID" \
--content "Check with nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg on this" | jq .
# messages send from stdin — safe path for content with shell metacharacters
# (backticks, $vars, code blocks) that would otherwise be expanded by the shell.
echo 'Body with `backticks` and $vars stays literal.' \
| buzz messages send --channel "$CHANNEL_ID" --content - | jq .
# messages get
buzz messages get --channel "$CHANNEL_ID" | jq .
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
# messages thread
buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
# messages search
buzz messages search --query "Hello" | jq .
buzz messages search --query "CLI test" --limit 5 | jq .
# messages edit
buzz messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# messages delete
buzz messages delete --event "$REPLY_ID" | jq .
```
### 6.4 Diff Messages
```bash
# messages send-diff from stdin
echo '--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
-fn old() {}
+fn new() {}' | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" | jq .
# messages send-diff with metadata
echo "diff content" | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" \
--file "src/main.rs" \
--lang "rust" \
--description "Refactored main" | jq .
# messages send-diff with branch + PR metadata
echo "diff content" | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" \
--parent-commit "1234567890abcdef1234567890abcdef12345678" \
--source-branch "feature/cli" \
--target-branch "main" \
--pr 42 | jq .
```
### 6.5 Reactions
```bash
# Send a message to react to
REACT_MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "React to this")
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')
# reactions add
buzz reactions add --event "$REACT_ID" --emoji "👍" | jq .
# reactions get
buzz reactions get --event "$REACT_ID" | jq .
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}
# reactions remove
buzz reactions remove --event "$REACT_ID" --emoji "👍" | jq .
```
### 6.6 DMs
```bash
# dms list
buzz dms list | jq .
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]
# dms open (needs a real pubkey — use your own or a test one)
# Get your own pubkey first:
MY_PUBKEY=$(buzz users get | jq -r '.[0].pubkey // empty')
echo "My pubkey: $MY_PUBKEY"
# dms open with a synthetic pubkey (relay will create the user)
DM_RESULT=$(buzz dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
echo "$DM_RESULT" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')
# dms add-member (requires messages:write scope — NOT admin:channels)
buzz dms add-member --channel "$DM_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq .
```
### 6.7 Users & Presence
```bash
# users get — own profile (0 pubkeys)
buzz users get | jq .
# Expected: [{...profile...}] — always returns an array, even for single results
# users get — single pubkey
buzz users get --pubkey "$MY_PUBKEY" | jq .
# users get — batch (2+ pubkeys)
buzz users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
# users set-profile
buzz users set-profile --name "CLI Test Agent" --about "Testing buzz-cli" | jq .
# users presence
buzz users presence --pubkeys "$MY_PUBKEY" | jq .
# users set-presence
buzz users set-presence --status online | jq .
buzz users set-presence --status away | jq .
buzz users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
```
### 6.8 Channel Members (add/remove require admin:channels)
```bash
# channels add-member
buzz channels add-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" \
--role member | jq .
# channels members
buzz channels members --channel "$CHANNEL_ID" | jq .
# Expected: [{"pubkey":"...","role":"..."}]
# channels remove-member
buzz channels remove-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq .
```
### 6.9 Workflows
```bash
# workflows create
# NOTE: trigger uses `on:` tag (serde internally tagged enum).
# Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook
# Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc.
WF=$(buzz workflows create --channel "$CHANNEL_ID" \
--yaml 'name: test-wf
trigger:
on: webhook
steps:
- id: step1
action: send_message
text: "Hello from workflow"' | jq .)
echo "$WF"
WF_ID=$(echo "$WF" | jq -r '.workflow_id')
# workflows list
buzz workflows list --channel "$CHANNEL_ID" | jq .
# workflows get
buzz workflows get --workflow "$WF_ID" | jq .
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null
# workflows update (requires --channel)
buzz workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
steps:
- id: step1
action: send_message
text: "Updated"' | jq .
# workflows trigger
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
# definitions into a DB table asynchronously. If the definition event hasn't
# been indexed yet, the trigger handler won't find it.
buzz workflows trigger --workflow "$WF_ID" | jq .
# workflows runs
buzz workflows runs --workflow "$WF_ID" | jq .
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
# Test the validation instead:
buzz workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true
# Should fail with relay error (token not found), not a validation error
# To test the deny path: buzz workflows approve --token <UUID> --approved false
# workflows delete
buzz workflows delete --workflow "$WF_ID" | jq .
```
### 6.10 Feed
```bash
buzz feed get | jq .
buzz feed get --limit 5 | jq .
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
```
### 6.11 Forum & Voting
```bash
# Send a forum post (kind 45001) to the forum channel
FORUM_POST=$(buzz messages send --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')
# messages vote (up)
buzz messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
# messages vote (down)
buzz messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
```
### 6.12 Notes (NIP-23 long-form, kind:30023)
Editable team-knowledge notes keyed by `(kind:30023, you, d=slug)`. `set` is an
idempotent upsert; `rm` is a NIP-09 a-tag deletion. Output is plain text (refs),
not JSON — except `get`/`ls`, which emit JSON.
```bash
# set (first publish — --title required, body from stdin)
cat <<'EOF' | buzz notes set --name dco-check --title "DCO Check" \
--summary "How we verify DCO" --tag dco --tag ci --content -
Run `git log --format='%(trailers:key=Signed-off-by)'` ...
EOF
# → prints event_id / naddr / coordinate / slug / title
# set (edit — omit --title to carry it forward; published_at preserved)
echo "Updated body." | buzz notes set --name dco-check --content -
# get by name (own author resolves directly; cross-author #d query otherwise)
buzz notes get --name dco-check | jq .
buzz notes get --name dco-check --content-only
# get by naddr (exact coordinate; paste the naddr from a set/get above)
buzz notes get --naddr "$NADDR" | jq .
# ls (own by default; --author all across the team; --tag filters)
buzz notes ls | jq .
buzz notes ls --tag dco | jq .
buzz notes ls --author all --limit 10 | jq .
# rm (NIP-09 a-tag deletion; subsequent get must 404)
buzz notes rm --name dco-check
# → prints deleted <coordinate> / deletion <event-id>
buzz notes get --name dco-check # exits non-zero: not found
# rm of a slug you never published → NotFound, no kind:5 emitted
buzz notes rm --name does-not-exist # exits non-zero
```
---
## 7. Error Path Testing
Verify the CLI produces correct JSON on stderr and correct exit codes.
```bash
# Exit 1: Invalid UUID
buzz channels get --channel "not-a-uuid" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"}
# exit: 1
# Exit 1: Invalid hex64
buzz messages delete --event "not-hex" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
# exit: 1
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
buzz channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
# exit: 1
# Exit 1: Invalid --direction value
buzz messages vote --event "$(printf '0%.0s' {1..64})" \
--direction sideways 2>&1; echo "exit: $?"
# exit: 1
# Exit 1: Empty body guard
buzz users set-profile 2>&1; echo "exit: $?"
# exit: 1 (at least one field required)
# Exit 3: No auth configured
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
# Not-found returns null, not an error (exit 0)
buzz channels get --channel "00000000-0000-0000-0000-000000000000"
# stdout: null
# exit: 0
```
---
## 8. Auth Testing
Test authentication.
```bash
# Private key (BUZZ_PRIVATE_KEY)
BUZZ_PRIVATE_KEY="nsec1..." buzz channels list | jq .
# Should succeed
# No auth → exit 3
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
```
---
## 9. Cleanup
```bash
# Delete test channels
buzz channels delete --channel "$CHANNEL_ID" | jq .
buzz channels delete --channel "$FORUM_ID" | jq .
```
---
## 10. Checklist
| # | Command | Tested | Notes |
|---|---------|:------:|-------|
| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions, stdin |
| 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR |
| 3 | `messages edit` | ☐ | |
| 4 | `messages delete` | ☐ | |
| 5 | `messages get` | ☐ | With limit |
| 6 | `messages thread` | ☐ | |
| 7 | `messages search` | ☐ | With limit |
| 8 | `messages vote` | ☐ | Up and down |
| 9 | `channels list` | ☐ | With visibility, member |
| 10 | `channels get` | ☐ | |
| 11 | `channels create` | ☐ | Stream and forum |
| 12 | `channels update` | ☐ | |
| 13 | `channels topic` | ☐ | |
| 14 | `channels purpose` | ☐ | |
| 15 | `channels join` | ☐ | |
| 16 | `channels leave` | ☐ | |
| 17 | `channels archive` | ☐ | Needs admin:channels |
| 18 | `channels unarchive` | ☐ | Needs admin:channels |
| 19 | `channels delete` | ☐ | Needs admin:channels |
| 20 | `channels members` | ☐ | |
| 21 | `channels add-member` | ☐ | Needs admin:channels |
| 22 | `channels remove-member` | ☐ | Needs admin:channels |
| 23 | `canvas get` | ☐ | |
| 24 | `canvas set` | ☐ | Direct and stdin |
| 25 | `reactions add` | ☐ | |
| 26 | `reactions remove` | ☐ | |
| 27 | `reactions get` | ☐ | |
| 28 | `dms list` | ☐ | |
| 29 | `dms open` | ☐ | |
| 30 | `dms add-member` | ☐ | Needs messages:write |
| 31 | `users get` | ☐ | Self, single, batch |
| 32 | `users set-profile` | ☐ | |
| 33 | `users presence` | ☐ | |
| 34 | `users set-presence` | ☐ | online, away, offline |
| 35 | `workflows list` | ☐ | |
| 36 | `workflows create` | ☐ | |
| 37 | `workflows update` | ☐ | |
| 38 | `workflows delete` | ☐ | |
| 39 | `workflows trigger` | ☐ | |
| 40 | `workflows runs` | ☐ | |
| 41 | `workflows get` | ☐ | |
| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny |
| 43 | `feed get` | ☐ | |
| 44 | `social publish` | ☐ | |
| 45 | `social set-contacts` | ☐ | |
| 46 | `social event` | ☐ | |
| 47 | `social notes` | ☐ | |
| 48 | `social contacts` | ☐ | |
| 49 | `repos create` | ☐ | |
| 50 | `repos get` | ☐ | |
| 51 | `repos list` | ☐ | |
| 52 | `upload file` | ☐ | |
| 53 | `pack validate` | ☐ | Local, no relay |
| 54 | `pack inspect` | ☐ | Local, no relay |
| 55 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard |
| 56 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
| 57 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
| 58 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |

View File

@@ -0,0 +1,595 @@
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
use crate::error::CliError;
// ---------------------------------------------------------------------------
// Blob / Media types
// ---------------------------------------------------------------------------
/// Descriptor returned by the relay after a successful upload.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BlobDescriptor {
/// Public URL of the uploaded blob.
pub url: String,
/// Hex-encoded SHA-256 of the file content.
pub sha256: String,
/// File size in bytes.
pub size: u64,
/// MIME type (e.g. `image/jpeg`).
#[serde(rename = "type")]
pub mime_type: String,
/// Unix timestamp when the file was uploaded.
pub uploaded: i64,
/// Image dimensions as `<width>x<height>` (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash placeholder string (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Duration in seconds for video/audio (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
let mut tag = vec![
"imeta".to_string(),
format!("url {}", d.url),
format!("m {}", d.mime_type),
format!("x {}", d.sha256),
format!("size {}", d.size),
];
if let Some(ref dim) = d.dim {
tag.push(format!("dim {dim}"));
}
if let Some(ref bh) = d.blurhash {
tag.push(format!("blurhash {bh}"));
}
if let Some(ref th) = d.thumb {
tag.push(format!("thumb {th}"));
}
if let Some(dur) = d.duration {
tag.push(format!("duration {dur}"));
}
tag
}
/// MIME types accepted for upload.
const ALLOWED_MIMES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
];
/// Maximum file size for image uploads (50 MB).
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum file size for video uploads (500 MB).
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
// ---------------------------------------------------------------------------
// NIP-98 HTTP Auth
// ---------------------------------------------------------------------------
/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value.
///
/// The event includes:
/// - `u` tag: the full request URL
/// - `method` tag: HTTP method (GET, POST, PUT, DELETE)
/// - `payload` tag: SHA-256 hex of the request body (if present)
fn sign_nip98(
keys: &Keys,
method: &str,
url: &str,
body: Option<&[u8]>,
) -> Result<String, CliError> {
let mut tags = vec![
Tag::parse(["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
Tag::parse(["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?,
// Nonce prevents replay rejection for rapid-fire requests with identical bodies.
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
];
if let Some(b) = body {
let hash = hex::encode(Sha256::digest(b));
tags.push(
Tag::parse(["payload", &hash])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?,
);
}
let event = EventBuilder::new(Kind::Custom(27235), "")
.tags(tags)
.sign_with_keys(keys)
.map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?;
let json = event.as_json();
Ok(format!("Nostr {}", B64.encode(json.as_bytes())))
}
// ---------------------------------------------------------------------------
// BuzzClient
// ---------------------------------------------------------------------------
pub struct BuzzClient {
http: reqwest::Client,
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.buzz.place"
keys: Keys,
/// Optional NIP-OA auth tag injected into every signed event.
auth_tag: Option<Tag>,
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
auth_tag_json: Option<String>,
}
impl BuzzClient {
pub fn new(
relay_url: String,
keys: Keys,
auth_tag: Option<Tag>,
auth_tag_json: Option<String>,
) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
Ok(Self {
http,
relay_url,
keys,
auth_tag,
auth_tag_json,
})
}
/// Get the keypair.
pub fn keys(&self) -> &Keys {
&self.keys
}
/// Get the relay base URL.
#[allow(dead_code)]
pub fn relay_url(&self) -> &str {
&self.relay_url
}
/// Return the owner pubkey carried by the NIP-OA auth tag, if any.
///
/// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the
/// owner pubkey lives at index 1.
pub fn auth_tag_owner_hex(&self) -> Option<String> {
self.auth_tag
.as_ref()
.map(|t| t.as_slice())
.and_then(|slice| slice.get(1).cloned())
}
/// Sign an event builder, injecting the NIP-OA auth tag if configured.
///
/// All event creation should go through this method to ensure consistent
/// auth tag injection. Callers MUST NOT add `auth` tags to the builder
/// before calling this method.
pub fn sign_event(&self, builder: EventBuilder) -> Result<nostr::Event, CliError> {
let builder = if let Some(ref tag) = self.auth_tag {
builder.tags([tag.clone()])
} else {
builder
};
let event = builder
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
// Enforce: auth tags may only come from self.auth_tag injection.
let auth_count = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
.count();
let expected = if self.auth_tag.is_some() { 1 } else { 0 };
if auth_count != expected {
return Err(CliError::Other(format!(
"event has {auth_count} auth tags — expected {expected}; \
callers must not add auth tags manually"
)));
}
Ok(event)
}
/// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation).
fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match self.auth_tag_json {
Some(ref json) => req.header("x-auth-tag", json),
None => req,
}
}
// -----------------------------------------------------------------------
// HTTP Bridge: POST /query
// -----------------------------------------------------------------------
/// Execute a one-shot query via the HTTP bridge.
/// `filter` is a Nostr filter object (will be wrapped in an array).
/// Returns the raw JSON response (array of events).
pub async fn query(&self, filter: &serde_json::Value) -> Result<String, CliError> {
self.query_multi(std::slice::from_ref(filter)).await
}
/// Execute a one-shot query with multiple filters via the HTTP bridge.
/// Each filter is ORed by the relay (standard Nostr REQ behavior).
pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result<String, CliError> {
let url = format!("{}/query", self.relay_url);
let body_bytes = serde_json::to_vec(filters)
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
/// Execute a one-shot count via the HTTP bridge.
/// Returns the count as a JSON string.
#[allow(dead_code)]
pub async fn count(&self, filter: &serde_json::Value) -> Result<String, CliError> {
let url = format!("{}/count", self.relay_url);
let body_bytes = serde_json::to_vec(&[filter])
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
// -----------------------------------------------------------------------
// HTTP Bridge: POST /events
// -----------------------------------------------------------------------
/// Submit a signed Nostr event via POST /events.
pub async fn submit_event(&self, event: nostr::Event) -> Result<String, CliError> {
let url = format!("{}/events", self.relay_url);
let body_bytes = serde_json::to_vec(&event)
.map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
// -----------------------------------------------------------------------
// WebSocket publish (ephemeral events)
// -----------------------------------------------------------------------
/// Publish an ephemeral event via WebSocket with NIP-42 authentication.
///
/// The relay rejects ephemeral kinds (20000–29999) over HTTP. Delegates to
/// `buzz_ws_client::publish_event` which handles connect, NIP-42 auth,
/// EVENT send, OK wait, and graceful close.
pub async fn publish_ephemeral_event(&self, event: nostr::Event) -> Result<String, CliError> {
let ws_url = to_ws_url(&self.relay_url);
let ok =
buzz_ws_client::publish_event(&ws_url, event, &self.keys, self.auth_tag.as_ref(), 10)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
if !ok.accepted {
return Err(CliError::Relay {
status: 400,
body: ok.message,
});
}
Ok(serde_json::json!({
"event_id": ok.event_id,
"accepted": true,
"message": ok.message,
})
.to_string())
}
// -----------------------------------------------------------------------
// File upload (Blossom protocol)
// -----------------------------------------------------------------------
/// Upload a file to the relay's Blossom endpoint.
/// Returns a BlobDescriptor on success.
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
// 1. Read file — validate it exists and is a regular file
let metadata = std::fs::metadata(file_path)
.map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?;
if !metadata.is_file() {
return Err(CliError::Usage(format!("{file_path} is not a file")));
}
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !ALLOWED_MIMES.contains(&mime.as_str()) {
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
}
// 3. Size check
let max = if mime.starts_with("video/") {
MAX_VIDEO_BYTES
} else {
MAX_IMAGE_BYTES
};
if bytes.len() as u64 > max {
return Err(CliError::Usage(format!(
"file too large: {} bytes (max {})",
bytes.len(),
max
)));
}
// 4. SHA-256
let sha256 = hex::encode(Sha256::digest(&bytes));
// 5. Sign Blossom auth event (kind:24242)
use nostr::Timestamp;
let now = Timestamp::now().as_secs();
let expiry = if mime.starts_with("video/") {
3600
} else {
600
};
let exp_str = (now + expiry).to_string();
let mut blossom_tags = vec![
Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?,
Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
];
// Extract server domain from relay URL for BUD-11 server tag
if let Ok(parsed) = url::Url::parse(&self.relay_url) {
if let Some(host) = parsed.host_str() {
let domain = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
blossom_tags.push(
Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?,
);
}
}
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file")
.tags(blossom_tags)
.sign_with_keys(&self.keys)
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
// 6. Base64url encode the auth event for the header
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let auth_header = format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
// 7. PUT request to /media/upload — with generous per-request timeout.
let upload_timeout = if mime.starts_with("video/") {
Duration::from_secs(600)
} else {
Duration::from_secs(120)
};
let url = format!("{}/media/upload", self.relay_url);
let req = self
.http
.put(&url)
.timeout(upload_timeout)
.header("Authorization", &auth_header)
.header("Content-Type", &mime)
.header("X-SHA-256", &sha256);
let resp = self.with_auth_tag(req).body(bytes).send().await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Relay { status, body });
}
resp.json::<BlobDescriptor>()
.await
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
}
// -----------------------------------------------------------------------
// Response handling
// -----------------------------------------------------------------------
async fn handle_response(&self, resp: reqwest::Response) -> Result<String, CliError> {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.or_else(|| v.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.unwrap_or(body);
if status == 403 && std::env::var("BUZZ_AUTH_TAG").is_ok() {
let message = format!(
"{message} (BUZZ_AUTH_TAG is set — it may be stale or revoked; try unsetting it)"
);
return Err(CliError::Relay {
status,
body: message,
});
}
return Err(CliError::Relay {
status,
body: message,
});
}
Ok(resp.text().await?)
}
}
// ---------------------------------------------------------------------------
// URL normalization
// ---------------------------------------------------------------------------
/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash.
/// BUZZ_RELAY_URL may be ws/wss (copied from MCP config).
pub fn normalize_relay_url(url: &str) -> String {
url.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
/// Convert an HTTP(S) relay base URL back to a WebSocket URL for NIP-01 connections.
fn to_ws_url(http_url: &str) -> String {
http_url
.replace("https://", "wss://")
.replace("http://", "ws://")
}
// ---------------------------------------------------------------------------
// Output normalization helpers
// ---------------------------------------------------------------------------
/// Normalize raw event JSON array into consistent shape.
/// Each event becomes: {id, pubkey, kind, content, created_at, tags}
pub fn normalize_events(events: &[serde_json::Value]) -> String {
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
})
})
.collect();
serde_json::to_string(&normalized).unwrap_or_default()
}
/// Extract the d-tag value from a Nostr event JSON object.
pub fn extract_d_tag(event: &serde_json::Value) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("d")
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract a named tag's value from a Nostr event JSON object.
/// Finds the first tag whose first element matches `key` and returns the second element.
pub fn extract_tag_value(event: &serde_json::Value, key: &str) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some(key)
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract all p-tags into [{pubkey, role}] from a Nostr event JSON object.
pub fn extract_p_tags(event: &serde_json::Value) -> Vec<serde_json::Value> {
event
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("p")
})
.map(|t| {
let a = t.as_array().unwrap();
serde_json::json!({
"pubkey": a.get(1).and_then(|v| v.as_str()).unwrap_or(""),
"role": a.get(3).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).unwrap_or("member"),
})
})
.collect()
})
.unwrap_or_default()
}
/// Print a create-command response, injecting the generated entity ID.
pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) {
let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({}));
v[id_key] = serde_json::json!(id_val);
if v.get("accepted").is_none() {
v["accepted"] = serde_json::json!(true);
}
println!("{v}");
}
/// Normalize a relay write-response into a consistent JSON object.
/// Relay returns: {"event_id": "...", "accepted": true, "message": "..."}
/// Falls back to raw text if parsing fails.
pub fn normalize_write_response(raw: &str) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
if v.get("event_id").is_some() || v.get("accepted").is_some() {
return serde_json::json!({
"event_id": v.get("event_id").and_then(|v| v.as_str()).unwrap_or(""),
"accepted": v.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
"message": v.get("message").and_then(|v| v.as_str()).unwrap_or(""),
})
.to_string();
}
}
raw.to_string()
}

View File

@@ -0,0 +1,714 @@
use uuid::Uuid;
use crate::client::{
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
print_create_response, BuzzClient,
};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
// ---------------------------------------------------------------------------
// Read commands — POST /query
// ---------------------------------------------------------------------------
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"channel_id": extract_d_tag(e),
"name": extract_tag_value(e, "name"),
"description": extract_tag_value(e, "about"),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
}
pub async fn cmd_list_channels(
client: &BuzzClient,
visibility: Option<&str>,
member: Option<bool>,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let effective_limit = limit.unwrap_or(500);
let raw = if member == Some(true) {
// Step 1: find channel IDs where we're a member (kind:39002)
let my_pk = client.keys().public_key().to_hex();
let member_filter = serde_json::json!({
"kinds": [39002],
"#p": [my_pk],
"limit": effective_limit
});
let member_resp = client.query(&member_filter).await?;
let member_events: Vec<serde_json::Value> =
serde_json::from_str(&member_resp).unwrap_or_default();
let channel_ids: Vec<String> = member_events
.iter()
.map(extract_d_tag)
.filter(|id| !id.is_empty())
.collect();
if channel_ids.is_empty() {
println!("[]");
return Ok(());
}
// Step 2: fetch kind:39000 metadata for those channels
let metadata_filter = serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
"limit": effective_limit
});
client.query(&metadata_filter).await?
} else {
let filter = serde_json::json!({
"kinds": [39000],
"limit": effective_limit
});
client.query(&filter).await?
};
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
let channels: Vec<serde_json::Value> = events
.iter()
.filter(|e| {
if let Some(vis) = visibility {
// NIP-29: relay emits ["public"] or ["private"] single-element tags
let nip29_tag = match vis {
"open" => "public",
_ => vis,
};
e.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter().any(|tag| {
tag.as_array()
.map(|a| {
a.len() == 1
&& a.first().and_then(|v| v.as_str()) == Some(nip29_tag)
})
.unwrap_or(false)
})
})
.unwrap_or(false)
} else {
true
}
})
.map(extract_channel_metadata)
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = channels
.iter()
.map(|c| {
serde_json::json!({
"channel_id": c.get("channel_id").cloned().unwrap_or_default(),
"name": c.get("name").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
/// Search channels by human-readable name (kind:39000 group metadata).
///
/// The relay's access control already filters out channels the caller can't see
/// (private channels they're not a member of), so we just post-filter the
/// returned events by name and project them into a stable JSON shape.
pub async fn cmd_search_channels(
client: &BuzzClient,
query: &str,
exact: bool,
include_archived: bool,
limit: u32,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--query cannot be empty".into()));
}
let filter = serde_json::json!({
"kinds": [39000],
"limit": limit,
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
let Some(arr) = events.as_array() else {
println!("[]");
return Ok(());
};
let needle = query.to_ascii_lowercase();
let mut matches: Vec<ChannelSummary> = arr
.iter()
.filter_map(ChannelSummary::from_event)
.filter(|c| if include_archived { true } else { !c.archived })
.filter(|c| name_matches(&c.name, &needle, exact))
.collect();
matches.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.channel_id.cmp(&b.channel_id))
});
let output = serde_json::to_string(&matches).expect("serializing ChannelSummary");
println!("{output}");
Ok(())
}
/// Stable, scriptable projection of a kind:39000 channel-metadata event.
#[derive(serde::Serialize)]
struct ChannelSummary {
channel_id: String,
name: String,
channel_type: Option<String>,
visibility: Option<String>,
archived: bool,
about: Option<String>,
topic: Option<String>,
purpose: Option<String>,
}
impl ChannelSummary {
/// Parse a kind:39000 event JSON value into a summary. Returns `None` if the
/// event lacks the required `d` (channel UUID) or `name` tags.
fn from_event(event: &serde_json::Value) -> Option<Self> {
let tags = event.get("tags")?.as_array()?;
let mut channel_id: Option<String> = None;
let mut name: Option<String> = None;
let mut channel_type: Option<String> = None;
let mut visibility: Option<String> = None;
let mut archived = false;
let mut about: Option<String> = None;
let mut topic: Option<String> = None;
let mut purpose: Option<String> = None;
for tag in tags {
let Some(tag_arr) = tag.as_array() else {
continue;
};
let key = tag_arr.first().and_then(|v| v.as_str()).unwrap_or("");
let val = tag_arr.get(1).and_then(|v| v.as_str());
match key {
"d" => channel_id = val.map(str::to_string),
"name" => name = val.map(str::to_string),
"t" => channel_type = val.map(str::to_string),
// NIP-29 emits both `private` and `public` (Buzz adds the latter).
// The presence of either tag is the source of truth; tag value is unused.
"private" => visibility = Some("private".to_string()),
"public" => visibility = Some("public".to_string()),
"about" => about = val.map(str::to_string),
"topic" => topic = val.map(str::to_string),
"purpose" => purpose = val.map(str::to_string),
"archived" => archived = val == Some("true"),
_ => {}
}
}
Some(ChannelSummary {
channel_id: channel_id?,
name: name?,
channel_type,
visibility,
archived,
about,
topic,
purpose,
})
}
}
fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool {
let hay = name.to_ascii_lowercase();
if exact {
hay == needle_lower
} else {
hay.contains(needle_lower)
}
}
pub async fn cmd_get_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [39000],
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let mut normalized = extract_channel_metadata(e);
normalized["pubkey"] =
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""));
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
pub async fn cmd_list_channel_members(
client: &BuzzClient,
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [39002],
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let members = events.first().map(extract_p_tags).unwrap_or_default();
let output = serde_json::to_string(&members).unwrap_or_default();
println!("{output}");
Ok(())
}
pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [40100],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(content) = events
.first()
.and_then(|e| e.get("content"))
.and_then(|c| c.as_str())
{
println!("{content}");
} else {
println!("null");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
pub async fn cmd_create_channel(
client: &BuzzClient,
name: &str,
channel_type: &str,
visibility: &str,
description: Option<&str>,
) -> Result<(), CliError> {
match channel_type {
"stream" | "forum" => {}
_ => {
return Err(CliError::Usage(format!(
"--type must be 'stream' or 'forum' (got: {channel_type})"
)))
}
}
match visibility {
"open" | "private" => {}
_ => {
return Err(CliError::Usage(format!(
"--visibility must be 'open' or 'private' (got: {visibility})"
)))
}
}
let channel_uuid = Uuid::new_v4();
let vis = match visibility {
"open" => buzz_sdk::Visibility::Open,
"private" => buzz_sdk::Visibility::Private,
_ => unreachable!(),
};
let ct = match channel_type {
"stream" => buzz_sdk::ChannelKind::Stream,
"forum" => buzz_sdk::ChannelKind::Forum,
_ => unreachable!(),
};
let builder =
buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
print_create_response(&resp, "channel_id", &channel_uuid.to_string());
Ok(())
}
pub async fn cmd_update_channel(
client: &BuzzClient,
channel_id: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<(), CliError> {
if name.is_none() && description.is_none() {
return Err(CliError::Usage(
"at least one field required (--name, --description)".into(),
));
}
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_update_channel(channel_uuid, name, description, None, None)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_channel_topic(
client: &BuzzClient,
channel_id: &str,
topic: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_topic(channel_uuid, topic)
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_channel_purpose(
client: &BuzzClient,
channel_id: &str,
purpose: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_purpose(channel_uuid, purpose)
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_join_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_join(channel_uuid)
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_leave_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_leave(channel_uuid)
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_archive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_archive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_unarchive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_unarchive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_delete_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_delete_channel(channel_uuid)
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_add_channel_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
role: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
let typed_role = match role {
None => None,
Some("owner") => Some(buzz_sdk::MemberRole::Owner),
Some("admin") => Some(buzz_sdk::MemberRole::Admin),
Some("member") => Some(buzz_sdk::MemberRole::Member),
Some("guest") => Some(buzz_sdk::MemberRole::Guest),
Some("bot") => Some(buzz_sdk::MemberRole::Bot),
Some(other) => {
return Err(CliError::Usage(format!(
"--role must be owner/admin/member/guest/bot (got: {other})"
)))
}
};
let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_remove_channel_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey)
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> {
match policy {
"anyone" | "owner_only" | "nobody" => {}
_ => {
return Err(CliError::Usage(format!(
"--policy must be 'anyone', 'owner_only', or 'nobody' (got: {policy})"
)))
}
}
let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
use nostr::{EventBuilder, Kind};
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16),
&content,
)
.tags([]);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_canvas(
client: &BuzzClient,
channel_id: &str,
content: &str,
) -> Result<(), CliError> {
let content = read_or_stdin(content)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: crate::ChannelsCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::ChannelsCmd;
match cmd {
ChannelsCmd::List {
visibility,
member,
limit,
} => {
let vis_str = visibility.as_ref().map(|v| v.to_string());
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
}
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
ChannelsCmd::Search {
query,
exact,
include_archived,
limit,
} => cmd_search_channels(client, &query, exact, include_archived, limit).await,
ChannelsCmd::Create {
name,
channel_type,
visibility,
description,
} => {
cmd_create_channel(
client,
&name,
&channel_type.to_string(),
&visibility.to_string(),
description.as_deref(),
)
.await
}
ChannelsCmd::Update {
channel,
name,
description,
} => cmd_update_channel(client, &channel, name.as_deref(), description.as_deref()).await,
ChannelsCmd::Topic { channel, topic } => {
cmd_set_channel_topic(client, &channel, &topic).await
}
ChannelsCmd::Purpose { channel, purpose } => {
cmd_set_channel_purpose(client, &channel, &purpose).await
}
ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await,
ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await,
ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await,
ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await,
ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await,
ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await,
ChannelsCmd::AddMember {
channel,
pubkey,
role,
} => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await,
ChannelsCmd::RemoveMember { channel, pubkey } => {
cmd_remove_channel_member(client, &channel, &pubkey).await
}
ChannelsCmd::SetAddPolicy { policy } => cmd_set_add_policy(client, &policy).await,
}
}
pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::CanvasCmd;
match cmd {
CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await,
CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await,
}
}
#[cfg(test)]
mod tests {
use super::{name_matches, ChannelSummary};
use serde_json::json;
fn event(tags: serde_json::Value) -> serde_json::Value {
json!({ "tags": tags })
}
#[test]
fn from_event_extracts_known_tags() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "buzz-chat-composer"],
["t", "stream"],
["public"],
["about", "About text"],
["topic", "Composer work"],
["purpose", "Track UI for the composer"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111");
assert_eq!(s.name, "buzz-chat-composer");
assert_eq!(s.channel_type.as_deref(), Some("stream"));
assert_eq!(s.visibility.as_deref(), Some("public"));
assert!(!s.archived);
assert_eq!(s.about.as_deref(), Some("About text"));
assert_eq!(s.topic.as_deref(), Some("Composer work"));
assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer"));
}
#[test]
fn from_event_marks_archived() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "old-channel"],
["archived", "true"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert!(s.archived);
}
#[test]
fn from_event_marks_private() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "secret"],
["private"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.visibility.as_deref(), Some("private"));
}
#[test]
fn from_event_returns_none_without_required_tags() {
// missing `name`
let ev = event(json!([["d", "11111111-1111-1111-1111-111111111111"]]));
assert!(ChannelSummary::from_event(&ev).is_none());
// missing `d`
let ev = event(json!([["name", "no-id"]]));
assert!(ChannelSummary::from_event(&ev).is_none());
}
#[test]
fn from_event_tolerates_malformed_tags() {
// Non-array tag entry, empty tag, single-element tag — all must be skipped, not panic.
let ev = event(json!([
"not-an-array",
[],
["name"],
["d", "11111111-1111-1111-1111-111111111111"],
["name", "fine"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.name, "fine");
}
// `name_matches` takes a pre-lowercased needle (caller responsibility, set in
// cmd_search_channels). Tests follow the same contract.
#[test]
fn name_matches_substring_case_insensitive() {
assert!(name_matches("Buzz-Chat-Composer", "composer", false));
assert!(name_matches("Buzz-Chat-Composer", "buzz", false));
assert!(!name_matches("design", "composer", false));
}
#[test]
fn name_matches_exact_case_insensitive() {
assert!(name_matches("Buzz", "buzz", true));
assert!(!name_matches("Buzz-Chat", "buzz", true));
}
}

View File

@@ -0,0 +1,140 @@
use uuid::Uuid;
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, sdk_err, validate_hex64};
/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey.
pub async fn cmd_list_dms(client: &BuzzClient, limit: Option<u32>) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(50).min(200);
let filter = serde_json::json!({
"kinds": [41001],
"#p": [my_pk],
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = e
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let arr = tag.as_array()?;
if arr.first()?.as_str()? == "p" {
arr.get(1)?.as_str().map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"dm_id": dm_id,
"participants": participants,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag.
pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), CliError> {
if pubkeys.is_empty() || pubkeys.len() > 8 {
return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into()));
}
for pk in pubkeys {
validate_hex64(pk)?;
}
let dm_id = Uuid::new_v4().to_string();
let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect();
// build_dm_open doesn't accept a d-tag, so we build the event manually
// using the SDK builder and add the d-tag ourselves.
use nostr::{EventBuilder, Kind, Tag};
let mut tags: Vec<Tag> = refs
.iter()
.map(|pk| Tag::parse(["p", *pk]).map_err(|e| CliError::Other(format!("tag error: {e}"))))
.collect::<Result<Vec<_>, _>>()?;
tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
// Try to extract relay-assigned channel_id from response message.
// Relay returns: {"event_id":"...","accepted":true,"message":"response:{\"channel_id\":\"...\",\"created\":true}"}
let relay_dm_id = serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v.get("message")?.as_str().map(|s| s.to_string()))
.and_then(|msg| {
let json_part = msg.strip_prefix("response:")?;
serde_json::from_str::<serde_json::Value>(json_part).ok()
})
.and_then(|v| v.get("channel_id")?.as_str().map(|s| s.to_string()));
let final_dm_id = relay_dm_id.unwrap_or(dm_id);
let mut normalized: serde_json::Value =
serde_json::from_str(&resp).unwrap_or(serde_json::json!({}));
normalized["dm_id"] = serde_json::json!(final_dm_id);
if normalized.get("accepted").is_none() {
normalized["accepted"] = serde_json::json!(true);
}
println!("{normalized}");
Ok(())
}
/// Hide a DM channel — sign and submit a kind:41012 event with h-tag.
pub async fn cmd_hide_dm(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
use nostr::{EventBuilder, Kind, Tag};
let tags = vec![Tag::parse(["h", &channel_uuid.to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder =
EventBuilder::new(Kind::Custom(buzz_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Add a member to a DM group — sign and submit a kind:41011 event.
pub async fn cmd_add_dm_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::DmsCmd;
match cmd {
DmsCmd::List { limit } => cmd_list_dms(client, limit).await,
DmsCmd::Open { pubkeys } => cmd_open_dm(client, &pubkeys).await,
DmsCmd::AddMember { channel, pubkey } => cmd_add_dm_member(client, &channel, &pubkey).await,
DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await,
}
}

View File

@@ -0,0 +1,354 @@
use std::io::Read;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use buzz_sdk::CustomEmoji;
/// d-tag for a member's own custom emoji set (kind:30030). Mirrors the SDK
/// constant; the workspace palette is the union of every member's own set.
const CUSTOM_EMOJI_SET_D_TAG: &str = buzz_sdk::CUSTOM_EMOJI_SET_D_TAG;
/// Custom emoji entry in CLI output.
#[derive(Debug, serde::Serialize)]
struct EmojiEntry {
shortcode: String,
url: String,
}
/// Parse `["emoji", shortcode, url]` tags from one event into entries.
fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else {
return vec![];
};
let mut out = Vec::new();
for tag in tags {
let Some(parts) = tag.as_array() else {
continue;
};
if parts.first().and_then(|v| v.as_str()) != Some("emoji") {
continue;
}
let (Some(shortcode), Some(url)) = (
parts.get(1).and_then(|v| v.as_str()),
parts.get(2).and_then(|v| v.as_str()),
) else {
continue;
};
out.push(EmojiEntry {
shortcode: shortcode.to_string(),
url: url.to_string(),
});
}
out
}
/// Union every member's kind:30030 set, deduped by `(shortcode, url)`.
/// Stable, sorted by shortcode then url, so identical input yields identical output.
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<EmojiEntry> = Vec::new();
for event in events {
for entry in emoji_tags_of(event) {
if seen.insert((entry.shortcode.clone(), entry.url.clone())) {
out.push(entry);
}
}
}
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
out
}
/// List the workspace custom emoji palette: the union of every member's
/// own kind:30030 set (d=`buzz:custom-emoji`).
async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> {
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
let emojis = union_custom_emoji(&events);
let output = serde_json::json!({ "emojis": emojis });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}
/// Fetch the caller's own current custom emoji set (latest kind:30030 under
/// the d-tag, authored by the caller). Empty when none published yet.
async fn fetch_own_emoji(client: &BuzzClient) -> Result<Vec<CustomEmoji>, CliError> {
let me = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
"authors": [me],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse own emoji set: {e}")))?;
// The relay keeps only the latest per (pubkey, d_tag), but be defensive.
let Some(event) = events.last() else {
return Ok(vec![]);
};
Ok(emoji_tags_of(event)
.into_iter()
.map(|e| CustomEmoji {
shortcode: e.shortcode,
url: e.url,
})
.collect())
}
/// Publish the caller's own (replaced) kind:30030 set, signed as the caller.
async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> {
let builder = buzz_sdk::build_custom_emoji_set(emojis)
.map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Add/update a shortcode in the caller's own set (read-modify-write).
async fn cmd_set(client: &BuzzClient, shortcode: &str, url: &str) -> Result<(), CliError> {
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
emojis.retain(|e| e.shortcode != normalized);
emojis.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
publish_own_set(client, &emojis).await
}
/// Remove a shortcode from the caller's own set (read-modify-write).
async fn cmd_rm(client: &BuzzClient, shortcode: &str) -> Result<(), CliError> {
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
let before = emojis.len();
emojis.retain(|e| e.shortcode != normalized);
if emojis.len() == before {
// Nothing to remove; avoid republishing an unchanged set.
println!(
"{}",
serde_json::json!({"accepted": true, "message": "not present"})
);
return Ok(());
}
publish_own_set(client, &emojis).await
}
/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
/// never approach this size in practice.
const STDIN_MAX_BYTES: u64 = 10_000_000;
/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
/// `CliError::Other` on I/O failure.
fn read_source(file: Option<&str>) -> Result<String, CliError> {
match file {
Some(path) => std::fs::read_to_string(path)
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
None => {
let mut buf = String::new();
std::io::stdin()
.take(STDIN_MAX_BYTES)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
if buf.is_empty() {
return Err(CliError::Usage(
"no input: provide --file or pipe JSON to stdin".into(),
));
}
Ok(buf)
}
}
}
/// Write to a file path or stdout.
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
match file {
Some(path) => std::fs::write(path, output)
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
None => {
println!("{output}");
Ok(())
}
}
}
/// Export custom emojis to stdout or a file.
async fn cmd_export(
client: &BuzzClient,
file: Option<&str>,
scope: &crate::EmojiScope,
) -> Result<(), CliError> {
let entries: Vec<EmojiEntry> = match scope {
crate::EmojiScope::Own => {
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
.await?
.into_iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode,
url: e.url,
})
.collect();
// Sort to match union_custom_emoji output order so repeated
// export | import --replace cycles are stable.
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
entries
}
crate::EmojiScope::Workspace => {
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
union_custom_emoji(&events)
}
};
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
write_output(&output, file)
}
/// Import custom emojis from stdin or a file into the caller's own set.
async fn cmd_import(
client: &BuzzClient,
file: Option<&str>,
replace: bool,
dry_run: bool,
) -> Result<(), CliError> {
// 1. Read raw JSON
let raw = read_source(file)?;
// 2. Parse and extract ["emojis"] array
let parsed: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
let arr = parsed
.get("emojis")
.and_then(|v| v.as_array())
.ok_or_else(|| {
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
})?;
// 3–4. Parse each element and normalize shortcodes
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
let shortcode = item
.get("shortcode")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
let url = item
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
import_entries.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
}
// 5. Deduplicate within the import batch (first occurrence wins)
let mut seen = std::collections::HashSet::new();
import_entries.retain(|e| seen.insert(e.shortcode.clone()));
// 6. Build final set
let final_set: Vec<CustomEmoji> = if replace {
import_entries
} else {
let mut existing = fetch_own_emoji(client).await?;
let existing_shortcodes: std::collections::HashSet<String> =
existing.iter().map(|e| e.shortcode.clone()).collect();
for entry in import_entries {
if !existing_shortcodes.contains(&entry.shortcode) {
existing.push(entry);
}
}
existing
};
// 7. Dry-run: print final set to stdout, warn to stderr
if dry_run {
let entries: Vec<EmojiEntry> = final_set
.iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode.clone(),
url: e.url.clone(),
})
.collect();
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
println!("{output}");
eprintln!("(dry run — not published)");
return Ok(());
}
// 8. Publish
publish_own_set(client, &final_set).await
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::EmojiCmd;
match cmd {
EmojiCmd::List => cmd_list(client).await,
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
EmojiCmd::Import {
file,
replace,
dry_run,
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn union_dedups_by_shortcode_and_url_across_members() {
let events = vec![
serde_json::json!({
"tags": [
["d", "buzz:custom-emoji"],
["emoji", "zort", "https://example.com/zort.png"],
["emoji", "narf", "https://example.com/narf.png"]
]
}),
serde_json::json!({
"tags": [
["d", "buzz:custom-emoji"],
// exact duplicate (same shortcode+url) — collapses
["emoji", "narf", "https://example.com/narf.png"],
// same shortcode, different url — both kept (distinct pair)
["emoji", "zort", "https://example.com/zort2.png"]
]
}),
];
let emojis = union_custom_emoji(&events);
let pairs: Vec<(&str, &str)> = emojis
.iter()
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
.collect();
assert_eq!(
pairs,
vec![
("narf", "https://example.com/narf.png"),
("zort", "https://example.com/zort.png"),
("zort", "https://example.com/zort2.png"),
]
);
}
}

View File

@@ -0,0 +1,84 @@
use std::cmp::Reverse;
use crate::client::{normalize_events, BuzzClient};
use crate::error::CliError;
const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"];
/// Get activity feed — query events mentioning our pubkey (via p-tag).
pub async fn cmd_get_feed(
client: &BuzzClient,
since: Option<i64>,
limit: Option<u32>,
types: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);
let mut filter = serde_json::json!({
"#p": [my_pk],
"limit": limit
});
if let Some(s) = since {
filter["since"] = serde_json::json!(s);
}
if let Some(types_str) = types {
let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
for t in &type_list {
if !VALID_FEED_TYPES.contains(t) {
return Err(crate::error::CliError::Usage(format!(
"invalid feed type {t:?} — must be one of: {}",
VALID_FEED_TYPES.join(", ")
)));
}
}
filter["feed_types"] = serde_json::json!(type_list);
}
let resp = client.query(&filter).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)));
let normalized = normalize_events(&events);
let output = match format {
crate::OutputFormat::Compact => {
let evts: Vec<serde_json::Value> =
serde_json::from_str(&normalized).unwrap_or_default();
let compact: Vec<serde_json::Value> = evts
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").cloned().unwrap_or_default(),
"content": e.get("content").cloned().unwrap_or_default(),
"created_at": e.get("created_at").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => normalized,
};
println!("{output}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: crate::FeedCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::FeedCmd;
match cmd {
FeedCmd::Get {
since,
limit,
types,
} => cmd_get_feed(client, since, limit, types.as_deref(), format).await,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,964 @@
use buzz_sdk::{DiffMeta, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;
use crate::client::{normalize_events, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions,
MENTION_CAP,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extract the thread root event ID from a Nostr tag array.
///
/// Parses `"e"` tags with NIP-10 markers:
/// - If a `"root"` marker exists, returns that event ID.
/// - Otherwise, if only a `"reply"` marker exists, returns the reply target
/// (a direct reply's parent IS the root, and nested replies need that root
/// to thread correctly).
/// - If no thread markers exist, returns `None` (parent is a top-level message,
/// so it is itself the root).
fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
fn valid_event_id(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
let arr = tags.as_array()?;
let mut root = None;
let mut reply = None;
for tag in arr {
let Some(parts) = tag.as_array() else {
continue;
};
if parts.len() >= 4 && parts[0].as_str() == Some("e") {
// Defensively ignore malformed marker values so a bad tag on the
// parent event can't block the reply — fall back to root == parent.
let id = parts[1].as_str().filter(|s| valid_event_id(s));
match (parts[3].as_str(), id) {
(Some("root"), Some(id)) => root = Some(id.to_string()),
(Some("reply"), Some(id)) => reply = Some(id.to_string()),
_ => {}
}
}
}
root.or(reply)
}
/// Build a `ThreadRef` for a reply, given the immediate parent's event ID.
///
/// Fetches the parent event from the relay and inspects its NIP-10 `e` tags to
/// determine the thread root:
/// - Direct reply (parent is top-level): `root == parent`.
/// - Nested reply: `root` is the parent's own root marker; `parent` is unchanged.
///
/// Ensures CLI-sent replies thread correctly using the same NIP-10 logic.
async fn resolve_thread_ref(
client: &BuzzClient,
parent_event_id: &str,
) -> Result<ThreadRef, CliError> {
let parent_eid = parse_event_id(parent_event_id)?;
let filter = serde_json::json!({ "ids": [parent_event_id], "limit": 1 });
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let event = events
.as_array()
.and_then(|a| a.first())
.ok_or_else(|| CliError::Other(format!("parent event {parent_event_id} not found")))?;
let tags = event
.get("tags")
.cloned()
.unwrap_or(serde_json::Value::Null);
let root_eid = match find_root_from_tags(&tags) {
Some(root_hex) if root_hex != parent_event_id => parse_event_id(&root_hex)?,
_ => parent_eid,
};
Ok(ThreadRef {
root_event_id: root_eid,
parent_event_id: parent_eid,
})
}
/// Resolve the channel UUID for an event by querying for it via POST /query.
/// Extracts the `h` tag value from the returned event's tags.
async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result<Uuid, CliError> {
let filter = serde_json::json!({
"ids": [event_id]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("query response is not an array".into()))?;
let event = arr
.first()
.ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?;
let tags = event
.get("tags")
.and_then(|t| t.as_array())
.ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?;
for tag in tags {
if let Some(arr) = tag.as_array() {
if arr.first().and_then(|v| v.as_str()) == Some("h") {
if let Some(uuid_str) = arr.get(1).and_then(|v| v.as_str()) {
return Uuid::parse_str(uuid_str).map_err(|_| {
CliError::Other(format!("event h-tag is not a valid UUID: {uuid_str}"))
});
}
}
}
}
Err(CliError::Other(format!(
"event {event_id} has no h-tag — cannot determine channel"
)))
}
/// Resolve `@name` mentions in `content` against this channel's members.
///
/// Queries kind 39002 (channel members) then kind 0 (profiles), parses
/// display names once, and feeds them to [`extract_at_mentions_with_known`]
/// for multi-word matching. On any I/O or parse failure, returns an empty
/// vec — auto-tagging is best-effort and must never block a send.
async fn resolve_content_mentions(
client: &BuzzClient,
channel_id: &str,
content: &str,
) -> Vec<String> {
if !content.contains('@') {
return vec![];
}
// 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag).
let members_filter = serde_json::json!({
"kinds": [39002],
"#d": [channel_id],
"limit": 1,
});
let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await {
Some(pks) if !pks.is_empty() => pks,
_ => return vec![],
};
// 2. Profiles for those members (kind 0).
let profiles_filter = serde_json::json!({
"kinds": [0],
"authors": member_pubkeys,
"limit": member_pubkeys.len(),
});
let profile_events = match fetch_events(client, &profiles_filter).await {
Some(v) => v,
None => return vec![],
};
// 3. Single parse: extract (pubkey, display_name) pairs from profile JSON.
let mut name_to_pubkeys: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut display_names: Vec<String> = Vec::new();
for e in &profile_events {
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
continue;
};
let Some(content_json) = e.get("content").and_then(|v| v.as_str()) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(content_json) else {
continue;
};
let Some(name) = v
.get("display_name")
.or_else(|| v.get("name"))
.and_then(|n| n.as_str())
.filter(|n| !n.is_empty())
else {
continue;
};
let lower = name.to_ascii_lowercase();
name_to_pubkeys
.entry(lower)
.or_default()
.push(pubkey.to_string());
display_names.push(name.to_string());
}
// 4. Two-pass extraction: known multi-word names first, single-word fallback.
let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect();
let names = extract_at_mentions_with_known(content, &known_refs);
// 5. Look up matched names → pubkeys via the map we already built.
names
.iter()
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
.cloned()
.collect()
}
/// Fetch raw events for `filter` via the relay's `/query` endpoint.
/// Returns `None` on any I/O or parse failure.
async fn fetch_events(
client: &BuzzClient,
filter: &serde_json::Value,
) -> Option<Vec<serde_json::Value>> {
let raw = client.query(filter).await.ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.as_array().cloned()
}
/// Extract member pubkeys (the `p` tag values) from a single 39002 event.
async fn fetch_member_pubkeys(
client: &BuzzClient,
filter: &serde_json::Value,
) -> Option<Vec<String>> {
let events = fetch_events(client, filter).await?;
Some(parse_member_pubkeys(events.first()?))
}
/// Parse member pubkeys from a kind 39002 event JSON value.
///
/// Filters and canonicalizes via `nostr::PublicKey::from_hex` — matching
/// MCP's typed-Nostr behavior so both surfaces accept exactly the same
/// pubkeys. Pure helper, split out for testing.
fn parse_member_pubkeys(event: &serde_json::Value) -> Vec<String> {
let Some(tags) = event.get("tags").and_then(|t| t.as_array()) else {
return vec![];
};
tags.iter()
.filter_map(|t| {
let arr = t.as_array()?;
if arr.first()?.as_str()? != "p" {
return None;
}
let pk = arr.get(1)?.as_str()?;
PublicKey::from_hex(pk).ok().map(|k| k.to_hex())
})
.collect()
}
// ---------------------------------------------------------------------------
// Read commands — POST /query
// ---------------------------------------------------------------------------
fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
match format {
crate::OutputFormat::Compact => {
let events: Vec<serde_json::Value> =
serde_json::from_str(normalized).unwrap_or_default();
let compact: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").cloned().unwrap_or_default(),
"content": e.get("content").cloned().unwrap_or_default(),
"created_at": e.get("created_at").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => normalized.to_string(),
}
}
pub async fn cmd_get_messages(
client: &BuzzClient,
channel_id: &str,
limit: Option<u32>,
before: Option<i64>,
since: Option<i64>,
kinds: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let limit = limit.unwrap_or(50).min(200);
let mut filter = serde_json::json!({
"kinds": [9, 40002, 40008, 45001, 45003],
"#h": [channel_id],
"limit": limit
});
// If specific kinds requested, override
if let Some(k) = kinds {
let kind_list: Vec<u64> = k.split(',').filter_map(|s| s.trim().parse().ok()).collect();
if !kind_list.is_empty() {
filter["kinds"] = serde_json::json!(kind_list);
}
}
if let Some(b) = before {
filter["until"] = serde_json::json!(b);
}
if let Some(s) = since {
filter["since"] = serde_json::json!(s);
}
let resp = client.query(&filter).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
pub async fn cmd_get_thread(
client: &BuzzClient,
channel_id: &str,
event_id: &str,
limit: Option<u32>,
depth_limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(event_id)?;
let limit = limit.unwrap_or(100).min(500);
// Two filters ORed in a single HTTP call:
// 1. Replies referencing this event via e-tag (no kind restriction)
// 2. The root event itself by ID
let mut reply_filter = serde_json::json!({
"kinds": [9, 40002, 40003, 40008, 45003],
"#h": [channel_id],
"#e": [event_id],
"limit": limit
});
if let Some(d) = depth_limit {
reply_filter["depth_limit"] = serde_json::json!(d);
}
let root_filter = serde_json::json!({
"ids": [event_id],
"limit": 1
});
let resp = client.query_multi(&[reply_filter, root_filter]).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
pub async fn cmd_search(
client: &BuzzClient,
query: &str,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let limit = limit.unwrap_or(20).min(100);
let filter = serde_json::json!({
"kinds": [9, 40002, 45001, 45003],
"search": query,
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
// ---------------------------------------------------------------------------
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
pub struct SendMessageParams {
pub channel_id: String,
pub content: String,
pub kind: Option<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
pub files: Vec<String>,
}
pub async fn cmd_send_message(
client: &BuzzClient,
mut p: SendMessageParams,
) -> Result<(), CliError> {
// Allow '-' to read content from stdin. This keeps callers from having to
// jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
}
let channel_uuid = parse_uuid(&p.channel_id)?;
// Upload files and build imeta tags
let mut media_tags: Vec<Vec<String>> = Vec::new();
let mut media_content = String::new();
for file_path in &p.files {
let desc = client
.upload_file(file_path)
.await
.map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?;
media_tags.push(crate::client::build_imeta_tag(&desc));
if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
} else {
media_content.push_str("\n![image](");
}
media_content.push_str(&desc.url);
media_content.push(')');
}
let final_content = if media_content.is_empty() {
p.content.clone()
} else {
format!("{}{media_content}", p.content)
};
// Build thread ref if replying. `--reply-to` is the immediate parent; the
// thread root is derived from the parent's NIP-10 tags via the relay.
let thread_ref = if let Some(ref r) = p.reply_to {
Some(resolve_thread_ref(client, r).await?)
} else {
None
};
// Resolve @name mentions in the author-written body only — not the media markdown we
// append above, which is derived from upload metadata and can't carry `@names`.
let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await;
// NIP-27: also extract nostr:npub1… inline references (skipping code regions)
let stripped = strip_code_regions(&p.content);
let uri_pubkeys = extract_nostr_uris(&stripped);
merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP);
let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect();
let builder = match p.kind {
Some(45001) => {
buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?
}
Some(45003) => {
let tr = thread_ref.as_ref().ok_or_else(|| {
CliError::Usage("--reply-to is required for forum comments (kind 45003)".into())
})?;
buzz_sdk::build_forum_comment(
channel_uuid,
&final_content,
tr,
&mention_refs,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))?
}
None | Some(9) => buzz_sdk::build_message(
channel_uuid,
&final_content,
thread_ref.as_ref(),
&mention_refs,
p.broadcast,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?,
Some(k) => {
return Err(CliError::Usage(format!(
"--kind {k} is not supported (use 9, 45001, or 45003)"
)))
}
};
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub struct SendDiffParams {
pub channel_id: String,
pub diff: String,
pub repo_url: String,
pub commit_sha: String,
pub file_path: Option<String>,
pub parent_commit_sha: Option<String>,
pub source_branch: Option<String>,
pub target_branch: Option<String>,
pub pr_number: Option<u32>,
pub language: Option<String>,
pub description: Option<String>,
pub reply_to: Option<String>,
}
pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Result<(), CliError> {
if let Some(r) = &p.reply_to {
validate_hex64(r)?;
}
// Branch pairing: both or neither
match (&p.source_branch, &p.target_branch) {
(Some(_), None) | (None, Some(_)) => {
return Err(CliError::Usage(
"--source-branch and --target-branch must both be provided or both omitted".into(),
));
}
_ => {}
}
let channel_uuid = parse_uuid(&p.channel_id)?;
// Read diff from stdin if "--diff -"
let diff_content = read_or_stdin(&p.diff)?;
// Truncate at 60 KiB hunk boundary
let (diff, truncated) = truncate_diff(&diff_content, MAX_DIFF_BYTES);
// Language inference: explicit flag wins, then infer from file path
let language = p
.language
.clone()
.or_else(|| p.file_path.as_deref().and_then(infer_language));
// NIP-31 alt tag
let alt = match (&p.file_path, &p.description) {
(Some(fp), Some(desc)) => format!("Diff: {} — {}", fp, desc),
(Some(fp), None) => format!("Diff: {}", fp),
_ => "Diff".to_string(),
};
// `--reply-to` is the immediate parent; the thread root is derived from
// the parent's NIP-10 tags via the relay.
let thread_ref = if let Some(r) = &p.reply_to {
Some(resolve_thread_ref(client, r).await?)
} else {
None
};
let branch = match (&p.source_branch, &p.target_branch) {
(Some(src), Some(tgt)) => Some((src.clone(), tgt.clone())),
_ => None,
};
let diff_meta = DiffMeta {
repo_url: p.repo_url.clone(),
commit_sha: p.commit_sha.clone(),
file_path: p.file_path.clone(),
parent_commit: p.parent_commit_sha.clone(),
branch,
pr_number: p.pr_number,
language,
description: p.description.clone(),
truncated,
alt_text: Some(alt),
};
let builder =
buzz_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref())
.map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_delete_message(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = buzz_sdk::build_delete_message(channel_uuid, target_eid)
.map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Edit a message you previously sent.
pub async fn cmd_edit_message(
client: &BuzzClient,
event_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Vote on a forum post or comment.
pub async fn cmd_vote_on_post(
client: &BuzzClient,
event_id: &str,
direction: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let vote_dir = match direction {
"up" => VoteDirection::Up,
"down" => VoteDirection::Down,
_ => {
return Err(CliError::Usage(format!(
"--direction must be 'up' or 'down' (got: {direction})"
)))
}
};
// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;
let builder = buzz_sdk::build_vote(channel_uuid, target_eid, vote_dir)
.map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: crate::MessagesCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::MessagesCmd;
match cmd {
MessagesCmd::Send {
channel,
content,
kind,
reply_to,
broadcast,
files,
} => {
cmd_send_message(
client,
SendMessageParams {
channel_id: channel,
content,
kind,
reply_to,
broadcast,
files,
},
)
.await
}
MessagesCmd::SendDiff {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
cmd_send_diff_message(
client,
SendDiffParams {
channel_id: channel,
diff,
repo_url: repo,
commit_sha: commit,
file_path: file,
parent_commit_sha: parent_commit,
source_branch,
target_branch,
pr_number: pr,
language: lang,
description,
reply_to,
},
)
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await,
MessagesCmd::Get {
channel,
limit,
before,
since,
kinds,
} => {
cmd_get_messages(
client,
&channel,
limit,
before,
since,
kinds.as_deref(),
format,
)
.await
}
MessagesCmd::Thread {
channel,
event,
limit,
depth_limit,
} => cmd_get_thread(client, &channel, &event, limit, depth_limit, format).await,
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await,
MessagesCmd::Vote { event, direction } => {
cmd_vote_on_post(client, &event, &direction).await
}
}
}
#[cfg(test)]
mod tests {
use super::{find_root_from_tags, parse_member_pubkeys};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
};
use serde_json::json;
const ID_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
// Three real pubkeys (lowercase 64-char hex) used by parse_member_pubkeys tests.
// See the test's own comment on what `PublicKey::from_hex` actually validates.
const PK_VALID_A: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4";
const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05";
const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68";
#[test]
fn root_marker_wins_over_reply_marker() {
let tags = json!([
["e", ID_A, "", "root"],
["e", ID_B, "", "reply"],
["p", PUBKEY],
]);
assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A));
}
#[test]
fn reply_only_falls_back_to_reply_target() {
// Direct reply to a top-level message — the parent's only e-tag is a
// "reply" marker pointing at it; treat the reply target as the root.
let tags = json!([["e", ID_B, "", "reply"], ["p", PUBKEY],]);
assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_B));
}
#[test]
fn no_thread_markers_returns_none() {
let tags = json!([["p", PUBKEY], ["h", "channel-uuid"],]);
assert!(find_root_from_tags(&tags).is_none());
}
#[test]
fn unmarked_e_tag_ignored() {
// NIP-10 deprecated positional markers; ignore e-tags lacking an
// explicit "root"/"reply" marker rather than guessing.
let tags = json!([["e", ID_A], ["e", ID_B, ""],]);
assert!(find_root_from_tags(&tags).is_none());
}
#[test]
fn malformed_tags_are_skipped() {
let tags = json!([
"not-an-array",
["e"],
["e", "short"],
["e", ID_A, "", "root"],
]);
assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_A));
}
#[test]
fn malformed_marker_id_is_ignored() {
// Parent event has a "root" marker whose value isn't a valid 64-hex
// event id (other-client bug, relay-accepted). Treat the marker as
// absent so the caller falls back to root == parent rather than
// failing to send the reply.
let tags = json!([["e", "not-a-valid-id", "", "root"], ["p", PUBKEY],]);
assert!(find_root_from_tags(&tags).is_none());
}
#[test]
fn malformed_root_does_not_shadow_valid_reply() {
// If "root" is malformed but "reply" is valid, fall back to "reply".
let tags = json!([["e", "garbage", "", "root"], ["e", ID_B, "", "reply"],]);
assert_eq!(find_root_from_tags(&tags).as_deref(), Some(ID_B));
}
#[test]
fn non_array_input_returns_none() {
assert!(find_root_from_tags(&json!({})).is_none());
assert!(find_root_from_tags(&json!(null)).is_none());
}
// ── @mention resolution pipeline ────────────────────────────────────
//
// These tests don't hit the network — they prove that *given* the
// events the relay returns, the CLI's parse + match wiring produces
// the right pubkeys. The async I/O wrapper around them is one
// straight line; the pure stages it composes are exercised here and
// in buzz-sdk.
/// End-to-end (sans I/O): body text → extracted names → matched
/// member pubkeys, using realistic 39002 + kind:0 event JSON.
/// This is the regression guard for the previous stub that always
/// returned `vec![]`.
#[test]
fn cli_pipeline_resolves_body_at_names_to_member_pubkeys() {
// kind 39002 channel-members event with three members.
let members_event = json!({
"kind": 39002,
"tags": [
["d", "00000000-0000-0000-0000-000000000000"],
["p", PK_VALID_A, "", "member"],
["p", PK_VALID_B, "", "member"],
["p", PK_VALID_C, "", "member"],
],
"content": "",
});
assert_eq!(
parse_member_pubkeys(&members_event),
vec![PK_VALID_A, PK_VALID_B, PK_VALID_C]
);
// Three kind:0 profile events.
let entries = vec![
MentionProfile {
pubkey: PK_VALID_A,
content_json: r#"{"display_name":"Alice"}"#,
},
MentionProfile {
pubkey: PK_VALID_B,
content_json: r#"{"display_name":"Bob"}"#,
},
MentionProfile {
pubkey: PK_VALID_C,
content_json: r#"{"name":"Carol"}"#,
},
];
// Body mentions Alice and Carol (display_name fallback to `name`).
let names = extract_at_names("hello @alice and @CAROL");
let resolved = match_names_to_profiles(&names, &entries);
assert_eq!(resolved, vec![PK_VALID_A, PK_VALID_C]);
}
#[test]
fn cli_pipeline_resolves_multiword_display_names() {
let profile_events: Vec<serde_json::Value> = vec![
json!({
"pubkey": PK_VALID_A,
"content": r#"{"display_name":"Will Pfleger"}"#,
}),
json!({
"pubkey": PK_VALID_B,
"content": r#"{"display_name":"Alice"}"#,
}),
];
// Simulate the single-parse pipeline from resolve_content_mentions.
let mut name_to_pubkeys: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut display_names: Vec<String> = Vec::new();
for e in &profile_events {
let pubkey = e.get("pubkey").unwrap().as_str().unwrap();
let content_json = e.get("content").unwrap().as_str().unwrap();
let v: serde_json::Value = serde_json::from_str(content_json).unwrap();
let name = v
.get("display_name")
.or_else(|| v.get("name"))
.and_then(|n| n.as_str())
.filter(|n| !n.is_empty())
.unwrap();
let lower = name.to_ascii_lowercase();
name_to_pubkeys
.entry(lower)
.or_default()
.push(pubkey.to_string());
display_names.push(name.to_string());
}
let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect();
let names = extract_at_mentions_with_known("hey @Will Pfleger and @alice!", &known_refs);
assert_eq!(names, vec!["will pfleger", "alice"]);
let resolved: Vec<String> = names
.iter()
.flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten())
.cloned()
.collect();
assert_eq!(resolved, vec![PK_VALID_A, PK_VALID_B]);
}
#[test]
fn cli_pipeline_returns_empty_when_no_at_names() {
// Sanity: no `@names` in body → no profile match attempt needed.
let names = extract_at_names("plain message, no mentions");
assert!(names.is_empty());
}
#[test]
fn parse_member_pubkeys_ignores_non_p_tags() {
let event = json!({
"tags": [
["d", "channel-id"],
["p", PK_VALID_A],
["h", "channel-id"],
["e", "some-event"],
["p", PK_VALID_B, "wss://relay", "member"],
],
});
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_B]);
}
#[test]
fn parse_member_pubkeys_handles_malformed_event() {
assert!(parse_member_pubkeys(&json!({})).is_empty());
assert!(parse_member_pubkeys(&json!({"tags": "not an array"})).is_empty());
assert!(parse_member_pubkeys(&json!({"tags": [["p"]]})).is_empty());
}
#[test]
fn parse_member_pubkeys_filters_invalid_hex() {
// `PublicKey::from_hex` rejects non-hex and wrong-length inputs and
// canonicalizes hex case. (Note: it accepts any 64-char x-only hex
// whose integer value is in field; it does not verify the point is
// actually on the curve — same as MCP's behavior.)
let pk_uppercase: String = PK_VALID_A.to_ascii_uppercase();
let event = json!({
"tags": [
["p", PK_VALID_A], // valid, lowercase
["p", pk_uppercase], // valid hex, canonicalized to lowercase
["p", "too-short"], // length fail
["p", "z".repeat(64)], // non-hex chars
["p", "a".repeat(63)], // off-by-one length
],
});
assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]);
}
}

View File

@@ -0,0 +1,14 @@
pub mod channels;
pub mod dms;
pub mod emoji;
pub mod feed;
pub mod mem;
pub mod messages;
pub mod notes;
pub mod pack;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod upload;
pub mod users;
pub mod workflows;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
//! `buzz pack` subcommands — local persona pack operations.
//!
//! These commands operate on local pack directories. No relay connection needed.
use std::path::Path;
use crate::error::CliError;
/// Run `buzz pack validate <path>`.
///
/// Calls `validate_pack()` from the persona crate, prints diagnostics,
/// and exits with the appropriate code:
/// - 0: valid (may have warnings)
/// - 1: errors found
pub fn cmd_validate(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
return Err(CliError::Usage(format!("not a directory: {path}")));
}
let report = buzz_persona::validate::validate_pack(pack_dir);
for diag in &report.diagnostics {
match diag {
buzz_persona::validate::ValidationDiagnostic::Error(msg) => {
eprintln!(" ERROR: {msg}");
}
buzz_persona::validate::ValidationDiagnostic::Warning(msg) => {
eprintln!(" WARN: {msg}");
}
}
}
if report.has_errors() {
return Err(CliError::Usage("Validation failed.".into()));
} else if report.has_warnings() {
println!("Valid (with warnings).");
} else {
println!("Valid.");
}
Ok(())
}
/// Run `buzz pack inspect <path>`.
///
/// Loads and resolves a pack, then pretty-prints a summary of each persona's
/// effective configuration.
pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
return Err(CliError::Usage(format!("not a directory: {path}")));
}
// Resolve the pack — shows fully effective config (post-merge, post-split).
let pack = buzz_persona::resolve::resolve_pack(pack_dir)
.map_err(|e| CliError::Other(format!("failed to resolve pack: {e}")))?;
// Header
println!("Pack: {} ({})", pack.name, pack.id);
println!("Version: {}", pack.version);
println!("Personas: {}", pack.personas.len());
println!();
// Per-persona summary (fully resolved effective config)
for persona in &pack.personas {
println!(" {}", persona.name);
println!(" Display: {}", persona.display_name);
println!(" Description: {}", persona.description);
if let Some(ref llm_provider) = persona.llm_provider {
if let Some(ref model) = persona.model {
println!(" Model: {llm_provider}:{model}");
} else {
println!(" Provider: {llm_provider}");
}
} else if let Some(ref model) = persona.model {
println!(" Model: {model}");
}
if let Some(temp) = persona.temperature {
println!(" Temperature: {temp}");
}
if let Some(ctx) = persona.max_context_tokens {
println!(" Max context tokens: {ctx}");
}
if !persona.subscribe.is_empty() {
println!(" Subscribe: {}", persona.subscribe.join(", "));
}
let rt = &persona.triggers;
let mut parts = Vec::new();
if rt.mentions {
parts.push("mentions".to_string());
}
if !rt.keywords.is_empty() {
parts.push(format!("keywords {:?}", rt.keywords));
}
if rt.all_messages {
parts.push("all_messages".to_string());
}
if !parts.is_empty() {
println!(" Triggers: {}", parts.join(" + "));
}
println!(" Thread replies: {}", persona.thread_replies);
println!(" Broadcast replies: {}", persona.broadcast_replies);
if !persona.mcp_servers.is_empty() {
println!(" MCP servers: {}", persona.mcp_servers.len());
}
if !persona.skills.is_empty() {
println!(" Skills: {}", persona.skills.join(", "));
}
if let Some(ref avatar) = persona.avatar {
println!(" Avatar: {avatar}");
}
let prompt_preview = if persona.system_prompt.chars().count() > 80 {
let truncated: String = persona.system_prompt.chars().take(77).collect();
format!("{truncated}...")
} else {
persona.system_prompt.clone()
};
println!(
" System prompt: {} chars ({})",
persona.system_prompt.len(),
prompt_preview.replace('\n', " ")
);
if !persona.runtime_env_vars.is_empty() {
let env_str: Vec<String> = persona
.runtime_env_vars
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
println!(" Env vars: {}", env_str.join(", "));
}
println!();
}
Ok(())
}

View File

@@ -0,0 +1,142 @@
use std::collections::HashMap;
use nostr::EventId;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
pub async fn cmd_add_reaction(
client: &BuzzClient,
event_id: &str,
emoji: &str,
emoji_url: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let target_eid =
EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?;
let builder = if let Some(url) = emoji_url {
buzz_sdk::build_custom_emoji_reaction(target_eid, emoji, url)
.map_err(|e| CliError::Other(format!("build_custom_emoji_reaction failed: {e}")))?
} else {
buzz_sdk::build_reaction(target_eid, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?
};
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_remove_reaction(
client: &BuzzClient,
event_id: &str,
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let keys = client.keys();
// Find our reaction event by querying kind:7 reactions on this event from us
let my_pk = keys.public_key().to_hex();
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id],
"authors": [my_pk]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse reactions query: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?;
// Find the reaction event matching the emoji
let reaction_event_id = arr
.iter()
.find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji))
.and_then(|ev| ev.get("id").and_then(|id| id.as_str()))
.ok_or_else(|| {
CliError::Other(format!(
"no reaction with emoji '{emoji}' found for your pubkey on event {event_id}"
))
})?;
let reaction_eid = EventId::parse(reaction_event_id)
.map_err(|e| CliError::Other(format!("invalid reaction event ID: {e}")))?;
let builder = buzz_sdk::build_remove_reaction(reaction_eid)
.map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_get_reactions(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let mut groups: HashMap<String, Vec<String>> = HashMap::new();
for e in &events {
let emoji = e
.get("content")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("+")
.to_string();
let pubkey = e
.get("pubkey")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
groups.entry(emoji).or_default().push(pubkey);
}
let mut reactions: Vec<serde_json::Value> = groups
.into_iter()
.map(|(emoji, pubkeys)| {
serde_json::json!({
"emoji": emoji,
"count": pubkeys.len(),
"pubkeys": pubkeys,
})
})
.collect();
reactions.sort_by(|a, b| {
a.get("emoji")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or(""))
});
let output = serde_json::json!({ "reactions": reactions });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::ReactionsCmd;
match cmd {
ReactionsCmd::Add {
event,
emoji,
emoji_url,
} => cmd_add_reaction(client, &event, &emoji, emoji_url.as_deref()).await,
ReactionsCmd::Remove { event, emoji } => cmd_remove_reaction(client, &event, &emoji).await,
ReactionsCmd::Get { event } => cmd_get_reactions(client, &event).await,
}
}

View File

@@ -0,0 +1,128 @@
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::validate_repo_id;
// ---------------------------------------------------------------------------
// Create repo — publish kind:30617
// ---------------------------------------------------------------------------
pub async fn cmd_create_repo(
client: &BuzzClient,
repo_id: &str,
name: Option<&str>,
description: Option<&str>,
clone_urls: &[String],
web_url: Option<&str>,
relays: &[String],
) -> Result<(), CliError> {
validate_repo_id(repo_id)?;
let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect();
let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
let builder = buzz_sdk::build_repo_announcement(
repo_id,
name,
description,
&clone_refs,
web_url,
&relay_refs,
)
.map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Get repo — query kind:30617 by owner + d-tag
// ---------------------------------------------------------------------------
pub async fn cmd_get_repo(
client: &BuzzClient,
repo_id: &str,
owner: Option<&str>,
) -> Result<(), CliError> {
validate_repo_id(repo_id)?;
let mut filter = serde_json::json!({
"kinds": [30617],
"#d": [repo_id]
});
// If owner specified, filter by author pubkey; otherwise return any match.
// Note: without --owner, multiple repos with the same name (different owners) may be returned.
if let Some(pk) = owner {
crate::validate::validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// List repos — query kind:30617 by author
// ---------------------------------------------------------------------------
pub async fn cmd_list_repos(
client: &BuzzClient,
owner: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
// Default to self if no owner specified.
let pubkey = match owner {
Some(pk) => {
crate::validate::validate_hex64(pk)?;
pk.to_string()
}
None => client.keys().public_key().to_hex(),
};
let mut filter = serde_json::json!({
"kinds": [30617],
"authors": [pubkey]
});
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::ReposCmd;
match cmd {
ReposCmd::Create {
id,
name,
description,
clone_urls,
web,
relays,
} => {
cmd_create_repo(
client,
&id,
name.as_deref(),
description.as_deref(),
&clone_urls,
web.as_deref(),
&relays,
)
.await
}
ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await,
ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await,
}
}

View File

@@ -0,0 +1,288 @@
use buzz_sdk::kind::{
KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
};
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_event_id, validate_hex64};
/// A single contact entry (CLI-local, not from buzz-sdk).
#[derive(Debug, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}
pub async fn cmd_publish_note(
client: &BuzzClient,
content: &str,
reply_to: Option<&str>,
) -> Result<(), CliError> {
if let Some(r) = reply_to {
validate_hex64(r)?;
}
let reply_id = reply_to.map(parse_event_id).transpose()?;
let builder = buzz_sdk::build_note(content, reply_id)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_contact_list(
client: &BuzzClient,
contacts_json: &str,
) -> Result<(), CliError> {
let entries: Vec<ContactEntry> = serde_json::from_str(contacts_json)
.map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?;
let contacts: Vec<(&str, Option<&str>, Option<&str>)> = entries
.iter()
.map(|c| {
(
c.pubkey.as_str(),
c.relay_url.as_deref(),
c.petname.as_deref(),
)
})
.collect();
let builder = buzz_sdk::build_contact_list(&contacts)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Get a single event by ID via POST /query.
pub async fn cmd_get_event(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let filter = serde_json::json!({
"ids": [event_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get user notes (kind:1) by author pubkey.
pub async fn cmd_get_user_notes(
client: &BuzzClient,
pubkey: &str,
limit: Option<u32>,
before: Option<i64>,
before_id: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
if let Some(bid) = before_id {
validate_hex64(bid)?;
}
let limit = limit.unwrap_or(50).min(100);
let mut filter = serde_json::json!({
"kinds": [1],
"authors": [pubkey],
"limit": limit
});
if let Some(b) = before {
filter["until"] = serde_json::json!(b);
}
if let Some(bid) = before_id {
filter["before_id"] = serde_json::json!(bid);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get a user's contact list (kind:3) by pubkey.
pub async fn cmd_get_contact_list(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let filter = serde_json::json!({
"kinds": [3],
"authors": [pubkey],
"limit": 1
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
fn validate_social_list_kind(kind: u32) -> Result<(), CliError> {
match kind {
KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET => Ok(()),
_ => Err(CliError::Usage(format!(
"unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003"
))),
}
}
fn is_parameterized_social_list_kind(kind: u32) -> bool {
matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET)
}
fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, CliError> {
let raw_tags: Vec<Vec<String>> = serde_json::from_str(tags_json)
.map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?;
raw_tags
.iter()
.map(|parts| {
Tag::parse(parts.iter().map(String::as_str))
.map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}")))
})
.collect::<Result<_, _>>()
}
fn has_d_tag(tags: &[Tag]) -> bool {
tags.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d"))
}
pub async fn cmd_set_list(
client: &BuzzClient,
kind: u16,
tags_json: &str,
content: &str,
) -> Result<(), CliError> {
let kind_u32 = u32::from(kind);
validate_social_list_kind(kind_u32)?;
let tags = parse_tags_json(tags_json)?;
if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) {
return Err(CliError::Usage(format!(
"kind {kind} is parameterized replaceable and requires a d tag"
)));
}
let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_list(
client: &BuzzClient,
pubkey: &str,
kind: u32,
d_tag: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
validate_social_list_kind(kind)?;
if !is_parameterized_social_list_kind(kind) && d_tag.is_some() {
return Err(CliError::Usage(format!(
"kind {kind} is not parameterized; omit --d-tag"
)));
}
let mut filter = serde_json::json!({
"kinds": [kind],
"authors": [pubkey],
"limit": 10
});
if let Some(d) = d_tag {
filter["#d"] = serde_json::json!([d]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::SocialCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::SocialCmd;
match cmd {
SocialCmd::PublishNote { content, reply_to } => {
cmd_publish_note(client, &content, reply_to.as_deref()).await
}
SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await,
SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await,
SocialCmd::GetUserNotes {
pubkey,
limit,
before,
before_id,
} => cmd_get_user_notes(client, &pubkey, limit, before, before_id.as_deref()).await,
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
SocialCmd::SetList {
kind,
tags,
content,
} => cmd_set_list(client, kind, &tags, &content).await,
SocialCmd::GetList {
pubkey,
kind,
d_tag,
} => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn social_list_kind_validation_accepts_supported_kinds() {
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}");
}
}
#[test]
fn social_list_kind_validation_rejects_unsupported_kinds() {
let err = validate_social_list_kind(30002).unwrap_err();
assert!(
matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002"))
);
}
#[test]
fn parses_tags_json_and_detects_d_tag() {
let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#)
.expect("tags parse");
assert!(has_d_tag(&tags));
}
#[test]
fn malformed_tags_json_is_usage_error() {
let err = parse_tags_json("not json").unwrap_err();
assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON")));
}
#[test]
fn parameterized_social_list_kind_detection() {
assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET));
assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET));
assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST));
}
}

View File

@@ -0,0 +1,15 @@
use crate::client::BuzzClient;
use crate::error::CliError;
pub async fn dispatch(cmd: crate::UploadCmd, client: &BuzzClient) -> Result<(), CliError> {
match cmd {
crate::UploadCmd::File { file } => {
let desc = client.upload_file(&file).await?;
println!(
"{}",
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
);
Ok(())
}
}
}

View File

@@ -0,0 +1,325 @@
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
// TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder
/// Get user profiles (kind:0 metadata events).
///
/// - 0 pubkeys, no name → query our own profile
/// - 1+ pubkeys → query those users' profiles
/// - --name "foo" → NIP-50 search on kind:0, then client-side filter
pub async fn cmd_get_users(
client: &BuzzClient,
pubkeys: &[String],
name: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if let Some(query) = name {
if !pubkeys.is_empty() {
return Err(CliError::Usage(
"--name and --pubkey are mutually exclusive".into(),
));
}
return search_by_name(client, query, format).await;
}
for pk in pubkeys {
validate_hex64(pk)?;
}
if pubkeys.len() > 200 {
return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into()));
}
let my_pk = client.keys().public_key().to_hex();
let authors: Vec<&str> = if pubkeys.is_empty() {
vec![my_pk.as_str()]
} else {
pubkeys.iter().map(|s| s.as_str()).collect()
};
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len()
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let profiles: Vec<serde_json::Value> = events
.iter()
.filter_map(|e| {
let content_str = e.get("content")?.as_str()?;
let mut profile: serde_json::Value = serde_json::from_str(content_str).ok()?;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
/// Search for users by display name via NIP-50 full-text search on kind:0 profiles.
/// Returns [] if the relay does not implement NIP-50 search.
async fn search_by_name(
client: &BuzzClient,
query: &str,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--name cannot be empty".into()));
}
let filter = serde_json::json!({
"kinds": [0],
"search": query,
"limit": 100
});
let raw = client.query(&filter).await?;
// Parse and filter client-side for case-insensitive substring match
// on display_name or name fields (NIP-50 may return broader matches).
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
let Some(arr) = events.as_array() else {
println!("[]");
return Ok(());
};
let lower_query = query.to_ascii_lowercase();
let profiles: Vec<serde_json::Value> = arr
.iter()
.filter_map(|event| {
let content_str = event.get("content").and_then(|v| v.as_str())?;
let content: serde_json::Value = serde_json::from_str(content_str).ok()?;
let display_name = content
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
{
return None;
}
let mut profile = content;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
pub async fn cmd_set_profile(
client: &BuzzClient,
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<(), CliError> {
if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() {
return Err(CliError::Usage(
"at least one field required (--name, --avatar, --about, --nip05)".into(),
));
}
// Read-merge-write: fetch current profile, merge in the new fields, then sign.
let current = fetch_current_profile(client).await?;
// Merge: caller-supplied fields win; fall back to current profile values.
let merged_name = display_name
.map(|s| s.to_string())
.or_else(|| {
current
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| {
current
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_picture = avatar_url.map(|s| s.to_string()).or_else(|| {
current
.get("picture")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_about = about.map(|s| s.to_string()).or_else(|| {
current
.get("about")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_nip05 = nip05_handle.map(|s| s.to_string()).or_else(|| {
current
.get("nip05")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let builder = buzz_sdk::build_profile(
merged_name.as_deref(),
None, // `name` field (username) — not exposed by CLI
merged_picture.as_deref(),
merged_about.as_deref(),
merged_nip05.as_deref(),
)
.map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Fetch the current user's profile metadata via POST /query (kind:0).
/// Returns the parsed content JSON object, or an empty object if no profile exists.
async fn fetch_current_profile(
client: &BuzzClient,
) -> Result<serde_json::Map<String, serde_json::Value>, CliError> {
let my_pk = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [0],
"authors": [my_pk],
"limit": 1
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse profile query: {e}")))?;
let Some(arr) = events.as_array() else {
return Ok(serde_json::Map::new());
};
let Some(event) = arr.first() else {
return Ok(serde_json::Map::new());
};
// kind:0 content is a JSON string containing the profile fields
let content_str = event
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("{}");
let content: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default();
Ok(content.as_object().cloned().unwrap_or_default())
}
/// Get presence status for users — query kind:40902 presence snapshot events.
pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result<(), CliError> {
let pubkeys: Vec<&str> = pubkeys_csv
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for pk in &pubkeys {
validate_hex64(pk)?;
}
let filter = serde_json::json!({
"kinds": [40902],
"authors": pubkeys,
"limit": pubkeys.len()
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let presence: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
"status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&presence).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Set presence status — sign and submit a kind:20001 presence update event via WebSocket.
///
/// Kind 20001 is ephemeral and only accepted via WebSocket connections. This
/// method connects to the relay over WS, performs NIP-42 authentication, and
/// publishes the event directly — bypassing the HTTP bridge.
pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), CliError> {
let builder = buzz_sdk::build_presence_update(status).map_err(crate::validate::sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.publish_ephemeral_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(
cmd: crate::UsersCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::UsersCmd;
match cmd {
UsersCmd::Get { pubkeys, name } => {
cmd_get_users(client, &pubkeys, name.as_deref(), format).await
}
UsersCmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
cmd_set_profile(
client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await,
UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await,
}
}

View File

@@ -0,0 +1,250 @@
use sha2::{Digest, Sha256};
use crate::client::{extract_d_tag, normalize_write_response, print_create_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
// TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions
// ---------------------------------------------------------------------------
// Read commands — POST /query
// ---------------------------------------------------------------------------
/// List workflows in a channel — query kind:30620 workflow definition events.
pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [30620],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let workflows: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = serde_json::to_string(&workflows).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Get a single workflow definition.
pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let filter = serde_json::json!({
"kinds": [30620],
"#d": [workflow_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let normalized = serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
});
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
/// Get workflow run history — query kinds [46001, 46002, 46003].
///
/// NOTE: The relay does not currently emit workflow execution events (46001-46003).
/// Run history is stored in the workflow_runs DB table, not as Nostr events.
/// This command will return an empty array until the relay adds event emission
/// or a dedicated REST endpoint for run history.
pub async fn cmd_get_workflow_runs(
client: &BuzzClient,
workflow_id: &str,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let limit = limit.unwrap_or(20).min(100);
let filter = serde_json::json!({
"kinds": [46001, 46002, 46003],
"#d": [workflow_id],
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
})
})
.collect();
let output = serde_json::to_string(&normalized).unwrap_or_default();
println!("{output}");
Ok(())
}
// ---------------------------------------------------------------------------
// Write commands — signed events via POST /events
// ---------------------------------------------------------------------------
/// Create a workflow — sign and submit a kind:30620 event.
pub async fn cmd_create_workflow(
client: &BuzzClient,
channel_id: &str,
yaml: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let yaml_definition = read_or_stdin(yaml)?;
let workflow_id = uuid::Uuid::new_v4();
let builder = buzz_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition)
.map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
print_create_response(&resp, "workflow_id", &workflow_id.to_string());
Ok(())
}
/// Update a workflow — sign and submit an updated kind:30620 event with same d-tag.
pub async fn cmd_update_workflow(
client: &BuzzClient,
channel_id: &str,
workflow_id: &str,
yaml: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let wf_uuid = parse_uuid(workflow_id)?;
let yaml_definition = read_or_stdin(yaml)?;
let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition)
.map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Delete a workflow — sign and submit a kind:5 deletion event.
pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
let wf_uuid = parse_uuid(workflow_id)?;
let keys = client.keys();
let builder =
buzz_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Trigger a workflow — sign and submit a kind:46020 event.
///
/// When `inputs` is provided, it is parsed as a JSON object and used as the
/// event content (MCP parity). When omitted, the event content is `{}`.
pub async fn cmd_trigger_workflow(
client: &BuzzClient,
workflow_id: &str,
inputs: Option<&str>,
) -> Result<(), CliError> {
let wf_uuid = parse_uuid(workflow_id)?;
if let Some(raw) = inputs {
// Parse and validate it is a JSON object, then build the event manually
// so we can embed the inputs as the event content.
let parsed: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| CliError::Usage(format!("--inputs is not valid JSON: {e}")))?;
if !parsed.is_object() {
return Err(CliError::Usage("--inputs must be a JSON object".into()));
}
let content = serde_json::to_string(&parsed).unwrap_or_default();
use nostr::{EventBuilder, Kind, Tag};
let tags = vec![Tag::parse(["d", &wf_uuid.to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_WORKFLOW_TRIGGER as u16),
&content,
)
.tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
} else {
let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
}
Ok(())
}
/// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event.
pub async fn cmd_approve_step(
client: &BuzzClient,
approval_token: &str,
approved: bool,
note: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(approval_token)?;
let content = note.unwrap_or("");
// The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes()));
let builder =
buzz_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::WorkflowsCmd;
match cmd {
WorkflowsCmd::List { channel } => cmd_list_workflows(client, &channel).await,
WorkflowsCmd::Get { workflow } => cmd_get_workflow(client, &workflow).await,
WorkflowsCmd::Create { channel, yaml } => {
cmd_create_workflow(client, &channel, &yaml).await
}
WorkflowsCmd::Update {
channel,
workflow,
yaml,
} => cmd_update_workflow(client, &channel, &workflow, &yaml).await,
WorkflowsCmd::Delete { workflow } => cmd_delete_workflow(client, &workflow).await,
WorkflowsCmd::Trigger { workflow, inputs } => {
cmd_trigger_workflow(client, &workflow, inputs.as_deref()).await
}
WorkflowsCmd::Runs { workflow, limit } => {
cmd_get_workflow_runs(client, &workflow, limit).await
}
WorkflowsCmd::Approve {
token,
approved,
note,
} => {
// approved is already a bool — no parse_bool_flag needed
cmd_approve_step(client, &token, approved, note.as_deref()).await
}
}
}

View File

@@ -0,0 +1,86 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CliError {
/// Invalid argument or flag value — user error
#[error("{0}")]
Usage(String),
/// Relay returned a non-2xx response
#[error("relay error {status}: {body}")]
Relay { status: u16, body: String },
/// Network-level failure (connect, timeout, DNS)
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
/// Auth missing or rejected (401/403)
#[error("auth error: {0}")]
Auth(String),
/// Nostr key error (NIP-98 signing in `buzz auth`)
#[error("key error: {0}")]
Key(String),
/// Relay accepted the event but reported it as superseded by a newer
/// head — used by `buzz mem` set/rm to surface NIP-33 LWW conflicts.
#[error("conflict: {0}")]
Conflict(String),
/// Requested resource was absent or tombstoned (e.g. `buzz mem get`
/// for a slug with no head).
#[error("{0}")]
NotFound(String),
/// Catch-all for unexpected failures
#[error("{0}")]
Other(String),
}
/// Map CliError to process exit code.
/// 0=success (not an error), 1=user/not-found, 2=network/relay, 3=auth,
/// 4=other, 5=write conflict (NIP-33 dominated head).
pub fn exit_code(e: &CliError) -> i32 {
match e {
CliError::Usage(_) => 1,
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
3
} else {
2
}
}
CliError::Network(_) => 2,
CliError::Auth(_) => 3,
CliError::Key(_) => 3,
CliError::Conflict(_) => 5,
CliError::NotFound(_) => 1,
CliError::Other(_) => 4,
}
}
/// Serialize error to JSON and write to stderr.
/// Format: {"error": "<category>", "message": "<human-readable detail>"}
pub fn print_error(e: &CliError) {
let category = match e {
CliError::Usage(_) => "user_error",
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
"auth_error"
} else {
"relay_error"
}
}
CliError::Network(_) => "network_error",
CliError::Auth(_) => "auth_error",
CliError::Key(_) => "key_error",
CliError::Conflict(_) => "conflict",
CliError::NotFound(_) => "not_found",
CliError::Other(_) => "error",
};
let obj = serde_json::json!({
"error": category,
"message": e.to_string(),
});
eprintln!("{}", obj);
}

1380
crates/buzz-cli/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
#[tokio::main]
async fn main() {
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
}

View File

@@ -0,0 +1,461 @@
use crate::error::CliError;
/// Maximum content size in bytes (64 KiB).
pub const MAX_CONTENT_BYTES: usize = 65_536;
/// Maximum diff size in bytes (60 KiB).
pub const MAX_DIFF_BYTES: usize = 61_440;
/// Parse a hex string into a `nostr::EventId`. Returns `CliError::Usage` on failure.
pub fn parse_event_id(hex: &str) -> Result<nostr::EventId, CliError> {
nostr::EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
/// Parse a UUID string into a `uuid::Uuid`. Returns `CliError::Usage` on failure.
///
/// Note: `validate_uuid` (below) returns `()` for validation only; this function
/// returns the parsed `Uuid` for callers that need the value.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, CliError> {
uuid::Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}")))
}
/// Validate UUID string. Returns CliError::Usage on failure.
pub fn validate_uuid(s: &str) -> Result<(), CliError> {
uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?;
Ok(())
}
/// Validate 64-character lowercase hex string (event_id, pubkey).
pub fn validate_hex64(s: &str) -> Result<(), CliError> {
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(CliError::Usage(format!(
"must be a 64-character hex string: {s}"
)));
}
Ok(())
}
/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
pub fn validate_repo_id(s: &str) -> Result<(), CliError> {
if s.is_empty() || s.len() > 64 {
return Err(CliError::Usage(format!(
"repo ID must be 1-64 characters (got {})",
s.len()
)));
}
if s.starts_with('.') {
return Err(CliError::Usage("repo ID must not start with '.'".into()));
}
if s.contains("..") {
return Err(CliError::Usage("repo ID must not contain '..'".into()));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(CliError::Usage(format!(
"repo ID contains invalid characters (allowed: a-z A-Z 0-9 . _ -): {s}"
)));
}
Ok(())
}
/// Validate content does not exceed MAX_CONTENT_BYTES (65,536).
pub fn validate_content_size(content: &str) -> Result<(), CliError> {
if content.len() > MAX_CONTENT_BYTES {
return Err(CliError::Usage(format!(
"content exceeds maximum size ({} > {} bytes)",
content.len(),
MAX_CONTENT_BYTES
)));
}
Ok(())
}
/// Percent-encode for URL path segments and query parameter values.
/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
#[cfg(test)]
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
let hi = char::from_digit((byte >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase();
let lo = char::from_digit((byte & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase();
out.push('%');
out.push(hi);
out.push(lo);
}
}
}
out
}
/// Truncate diff at hunk boundary within max_bytes (60 KiB for send-diff-message).
/// Returns (truncated_string, was_truncated).
pub fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
const TRUNCATION_NOTICE: &str = "\n\n[diff truncated — exceeded size limit]";
if diff.len() <= max_bytes {
return (diff.to_string(), false);
}
let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
let utf8_boundary = diff
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= effective_limit)
.last()
.unwrap_or(0);
let safe_prefix = &diff[..utf8_boundary];
let cut_point = safe_prefix
.rfind("\n@@")
.filter(|&p| p > 0)
.unwrap_or_else(|| safe_prefix.rfind('\n').unwrap_or(utf8_boundary));
let mut result = diff[..cut_point].to_string();
result.push_str(TRUNCATION_NOTICE);
(result, true)
}
/// Infer syntax-highlight language from file extension.
pub fn infer_language(file_path: &str) -> Option<String> {
let ext = file_path.rsplit('.').next()?;
let lang = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"rb" => "ruby",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
"cs" => "csharp",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"scala" => "scala",
"sh" | "bash" | "zsh" => "bash",
"sql" => "sql",
"html" | "htm" => "html",
"css" | "scss" | "sass" => "css",
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"xml" => "xml",
"md" | "markdown" => "markdown",
"dockerfile" => "dockerfile",
_ => return None,
};
Some(lang.to_string())
}
/// Map `SdkError` to the appropriate `CliError` variant.
///
/// `InvalidInput` is a user error (exit 1), everything else is internal (exit 4).
pub fn sdk_err(e: buzz_sdk::SdkError) -> CliError {
match e {
buzz_sdk::SdkError::InvalidInput(msg) => CliError::Usage(msg),
other => CliError::Other(other.to_string()),
}
}
/// Read content from a string value or stdin if the value is "-".
pub fn read_or_stdin(value: &str) -> Result<String, CliError> {
if value == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("failed to read stdin: {e}")))?;
Ok(buf)
} else {
Ok(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- validate_uuid ---
#[test]
fn validate_uuid_valid() {
assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn validate_uuid_malformed() {
let err = validate_uuid("not-a-uuid").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_uuid_empty() {
let err = validate_uuid("").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
// --- validate_hex64 ---
#[test]
fn validate_hex64_valid() {
let hex = "a".repeat(64);
assert!(validate_hex64(&hex).is_ok());
}
#[test]
fn validate_hex64_all_digits() {
let hex = "0123456789abcdef".repeat(4);
assert!(validate_hex64(&hex).is_ok());
}
#[test]
fn validate_hex64_too_short() {
let hex = "a".repeat(63);
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_hex64_too_long() {
let hex = "a".repeat(65);
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_hex64_non_hex_char() {
let mut hex = "a".repeat(63);
hex.push('z'); // 'z' is not a hex digit
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
// --- validate_content_size ---
#[test]
fn validate_content_size_at_limit() {
let content = "x".repeat(MAX_CONTENT_BYTES);
assert!(validate_content_size(&content).is_ok());
}
#[test]
fn validate_content_size_over_limit() {
let content = "x".repeat(MAX_CONTENT_BYTES + 1);
let err = validate_content_size(&content).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_content_size_empty() {
assert!(validate_content_size("").is_ok());
}
// --- percent_encode ---
#[test]
fn percent_encode_unreserved_unchanged() {
let input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
assert_eq!(percent_encode(input), input);
}
#[test]
fn percent_encode_space() {
assert_eq!(percent_encode("hello world"), "hello%20world");
}
#[test]
fn percent_encode_slash() {
assert_eq!(percent_encode("a/b"), "a%2Fb");
}
#[test]
fn percent_encode_unicode_multibyte() {
// '€' is U+20AC, encoded as 3 UTF-8 bytes: 0xE2 0x82 0xAC
assert_eq!(percent_encode("€"), "%E2%82%AC");
}
#[test]
fn percent_encode_empty() {
assert_eq!(percent_encode(""), "");
}
// --- truncate_diff ---
#[test]
fn truncate_diff_under_limit_noop() {
let diff = "small diff";
let (result, was_truncated) = truncate_diff(diff, 1000);
assert_eq!(result, diff);
assert!(!was_truncated);
}
#[test]
fn truncate_diff_at_limit_noop() {
let diff = "x".repeat(100);
let (result, was_truncated) = truncate_diff(&diff, 100);
assert_eq!(result, diff);
assert!(!was_truncated);
}
#[test]
fn truncate_diff_cuts_at_hunk_boundary() {
// Build a diff with a @@ hunk marker after the limit
let hunk1 = "@@ -1,3 +1,3 @@\n line1\n line2\n line3\n";
let hunk2 = "@@ -5,3 +5,3 @@\n line4\n line5\n line6\n";
let diff = format!("{}{}", hunk1, hunk2);
// Limit to just past hunk1 but before hunk2 completes
let limit = hunk1.len() + 5;
let (result, was_truncated) = truncate_diff(&diff, limit);
assert!(was_truncated);
assert!(result.contains("[diff truncated — exceeded size limit]"));
// Should cut at the \n@@ boundary before hunk2
assert!(!result.contains("line4"));
}
#[test]
fn truncate_diff_falls_back_to_newline() {
// No @@ marker — should fall back to last newline
let diff = "line one\nline two\nline three extra long content here";
let limit = 20;
let (result, was_truncated) = truncate_diff(diff, limit);
assert!(was_truncated);
assert!(result.contains("[diff truncated — exceeded size limit]"));
}
#[test]
fn truncate_diff_appends_notice() {
let diff = "x".repeat(200);
let (result, was_truncated) = truncate_diff(&diff, 50);
assert!(was_truncated);
assert!(result.ends_with("[diff truncated — exceeded size limit]"));
}
// --- infer_language ---
#[test]
fn infer_language_rust() {
assert_eq!(infer_language("main.rs"), Some("rust".to_string()));
}
#[test]
fn infer_language_tsx() {
assert_eq!(infer_language("App.tsx"), Some("typescript".to_string()));
}
#[test]
fn infer_language_ts() {
assert_eq!(infer_language("index.ts"), Some("typescript".to_string()));
}
#[test]
fn infer_language_unknown_ext() {
assert_eq!(infer_language("file.xyz"), None);
}
#[test]
fn infer_language_no_ext() {
assert_eq!(infer_language("Makefile"), None);
}
#[test]
fn infer_language_path_with_dirs() {
assert_eq!(
infer_language("src/lib/utils.py"),
Some("python".to_string())
);
}
// Note: `extract_at_names`, `extract_at_mentions_with_known`, `merge_mentions`,
// and `normalize_mention_pubkeys` live in `buzz_sdk::mentions` and are tested there.
// --- parse_event_id ---
#[test]
fn parse_event_id_valid() {
let hex = "a".repeat(64);
assert!(super::parse_event_id(&hex).is_ok());
}
#[test]
fn parse_event_id_invalid() {
assert!(super::parse_event_id("not-a-hex-id").is_err());
}
// --- parse_uuid ---
#[test]
fn parse_uuid_valid() {
assert!(super::parse_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn parse_uuid_invalid() {
assert!(super::parse_uuid("not-a-uuid").is_err());
}
// ── validate_repo_id ─────────────────────────────────────────────────────
#[test]
fn validate_repo_id_valid() {
assert!(super::validate_repo_id("my-repo").is_ok());
assert!(super::validate_repo_id("repo_v2.0").is_ok());
assert!(super::validate_repo_id("a").is_ok());
}
#[test]
fn validate_repo_id_boundary_64_chars() {
let id = "a".repeat(64);
assert!(super::validate_repo_id(&id).is_ok());
}
#[test]
fn validate_repo_id_rejects_empty() {
assert!(super::validate_repo_id("").is_err());
}
#[test]
fn validate_repo_id_rejects_over_64() {
let id = "a".repeat(65);
assert!(super::validate_repo_id(&id).is_err());
}
#[test]
fn validate_repo_id_rejects_leading_dot() {
assert!(super::validate_repo_id(".hidden").is_err());
}
#[test]
fn validate_repo_id_rejects_double_dot() {
assert!(super::validate_repo_id("foo..bar").is_err());
}
#[test]
fn validate_repo_id_rejects_invalid_chars() {
assert!(super::validate_repo_id("my repo").is_err());
assert!(super::validate_repo_id("foo/bar").is_err());
assert!(super::validate_repo_id("a@b").is_err());
}
// --- read_or_stdin ---
#[test]
fn read_or_stdin_passthrough_returns_value() {
// Anything other than "-" is returned verbatim — backticks, $vars,
// newlines must all survive untouched (no shell evaluation happens
// here; we're past argv parsing).
let raw = "literal `backticks` and $vars\nwith newline";
assert_eq!(super::read_or_stdin(raw).unwrap(), raw);
}
#[test]
fn read_or_stdin_passthrough_empty_string() {
assert_eq!(super::read_or_stdin("").unwrap(), "");
}
}