mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
67
crates/buzz-cli/Cargo.toml
Normal file
67
crates/buzz-cli/Cargo.toml
Normal 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
154
crates/buzz-cli/README.md
Normal 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
606
crates/buzz-cli/TESTING.md
Normal 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 |
|
||||
595
crates/buzz-cli/src/client.rs
Normal file
595
crates/buzz-cli/src/client.rs
Normal 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()
|
||||
}
|
||||
714
crates/buzz-cli/src/commands/channels.rs
Normal file
714
crates/buzz-cli/src/commands/channels.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
140
crates/buzz-cli/src/commands/dms.rs
Normal file
140
crates/buzz-cli/src/commands/dms.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
354
crates/buzz-cli/src/commands/emoji.rs
Normal file
354
crates/buzz-cli/src/commands/emoji.rs
Normal 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"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
84
crates/buzz-cli/src/commands/feed.rs
Normal file
84
crates/buzz-cli/src/commands/feed.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
1049
crates/buzz-cli/src/commands/mem.rs
Normal file
1049
crates/buzz-cli/src/commands/mem.rs
Normal file
File diff suppressed because it is too large
Load Diff
964
crates/buzz-cli/src/commands/messages.rs
Normal file
964
crates/buzz-cli/src/commands/messages.rs
Normal 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;
|
||||
} else {
|
||||
media_content.push_str("\n;
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
14
crates/buzz-cli/src/commands/mod.rs
Normal file
14
crates/buzz-cli/src/commands/mod.rs
Normal 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;
|
||||
1366
crates/buzz-cli/src/commands/notes.rs
Normal file
1366
crates/buzz-cli/src/commands/notes.rs
Normal file
File diff suppressed because it is too large
Load Diff
151
crates/buzz-cli/src/commands/pack.rs
Normal file
151
crates/buzz-cli/src/commands/pack.rs
Normal 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(())
|
||||
}
|
||||
142
crates/buzz-cli/src/commands/reactions.rs
Normal file
142
crates/buzz-cli/src/commands/reactions.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
128
crates/buzz-cli/src/commands/repos.rs
Normal file
128
crates/buzz-cli/src/commands/repos.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
288
crates/buzz-cli/src/commands/social.rs
Normal file
288
crates/buzz-cli/src/commands/social.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
15
crates/buzz-cli/src/commands/upload.rs
Normal file
15
crates/buzz-cli/src/commands/upload.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
325
crates/buzz-cli/src/commands/users.rs
Normal file
325
crates/buzz-cli/src/commands/users.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
250
crates/buzz-cli/src/commands/workflows.rs
Normal file
250
crates/buzz-cli/src/commands/workflows.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
86
crates/buzz-cli/src/error.rs
Normal file
86
crates/buzz-cli/src/error.rs
Normal 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
1380
crates/buzz-cli/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
4
crates/buzz-cli/src/main.rs
Normal file
4
crates/buzz-cli/src/main.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
|
||||
}
|
||||
461
crates/buzz-cli/src/validate.rs
Normal file
461
crates/buzz-cli/src/validate.rs
Normal 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(), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user