Skip to content

Commit 0a2bc00

Browse files
author
Bartok9
committed
feat(cli): external-agent realtime path for block#2663
Add buzz listen (WS NDJSON + optional webhook), keep pubkey/tags in messages get --format compact, headless agents publish for kind:30177 with client-side schema validation, and docs/cli-external-agents.md. Includes users me for a self-contained E2E path (sibling block#2933). Signed-off-by: Bartok <danielrpike9@gmail.com> Signed-off-by: Bartok9 <danielrpike9@gmail.com>
1 parent 514a474 commit 0a2bc00

9 files changed

Lines changed: 739 additions & 6 deletions

File tree

crates/buzz-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ clap = { version = "4", features = ["derive", "env"] }
2323
reqwest = { workspace = true, features = ["json"] }
2424

2525
# Async runtime — tokio macros + multi-thread for reqwest
26-
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
26+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
2727

2828
# Serialization — JSON body building and response passthrough
2929
serde = { workspace = true }

crates/buzz-cli/src/client.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,11 @@ impl BuzzClient {
569569
&self.relay_url
570570
}
571571

572+
/// NIP-OA auth tag for WebSocket AUTH / EVENT signing, if configured.
573+
pub fn auth_tag(&self) -> Option<&nostr::Tag> {
574+
self.auth_tag.as_ref()
575+
}
576+
572577
/// Return the owner pubkey carried by the NIP-OA auth tag, if any.
573578
///
574579
/// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the

crates/buzz-cli/src/commands/agents.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
147147
Ok(())
148148
}
149149

150+
AgentsCmd::Publish {
151+
agent_pubkey,
152+
content,
153+
} => {
154+
let raw = crate::validate::read_or_stdin(&content)?;
155+
let body = crate::managed_agent_publish::validate_managed_agent_content(&raw)?;
156+
let builder =
157+
crate::managed_agent_publish::build_managed_agent_event(&agent_pubkey, &body)?;
158+
let event = client.sign_event(builder)?;
159+
let event_id = event.id.to_hex();
160+
client.submit_event(event).await?;
161+
println!(
162+
"{}",
163+
serde_json::json!({
164+
"ok": true,
165+
"event_id": event_id,
166+
"kind": 30177,
167+
"d": agent_pubkey,
168+
"action": "publish",
169+
})
170+
);
171+
Ok(())
172+
}
150173
AgentsCmd::Archived => cmd_archived(client).await,
151174
}
152175
}
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
//! `buzz listen` — persistent WebSocket stream of channel events (#2663 Option B).
2+
//!
3+
//! Prints one normalized JSON object per inbound EVENT matching the subscription
4+
//! filters (newline-delimited JSON on stdout). Uses the same NIP-42 / NIP-OA
5+
//! path as `publish_ephemeral_event`.
6+
7+
use std::io::Write;
8+
use std::sync::atomic::{AtomicBool, Ordering};
9+
use std::sync::Arc;
10+
use std::time::Duration;
11+
12+
use serde_json::json;
13+
use uuid::Uuid;
14+
15+
use crate::client::{normalize_events, BuzzClient};
16+
use crate::error::CliError;
17+
use crate::validate::validate_uuid;
18+
19+
/// Default kinds for channel traffic (matches `messages get` defaults).
20+
const DEFAULT_KINDS: &[u32] = &[9, 40002, 40008, 45001, 45003];
21+
22+
fn parse_kinds(raw: Option<&str>) -> Result<Vec<u32>, CliError> {
23+
match raw {
24+
None => Ok(DEFAULT_KINDS.to_vec()),
25+
Some(s) if s.trim().is_empty() => Ok(DEFAULT_KINDS.to_vec()),
26+
Some(s) => {
27+
let mut out = Vec::new();
28+
for part in s.split(',') {
29+
let part = part.trim();
30+
if part.is_empty() {
31+
continue;
32+
}
33+
let k: u32 = part
34+
.parse()
35+
.map_err(|_| CliError::Usage(format!("invalid kind in --kinds: {part}")))?;
36+
out.push(k);
37+
}
38+
if out.is_empty() {
39+
return Err(CliError::Usage("--kinds must list at least one kind".into()));
40+
}
41+
Ok(out)
42+
}
43+
}
44+
}
45+
46+
/// Build subscription filter list for REQ.
47+
pub(crate) fn build_listen_filters(
48+
channels: &[String],
49+
mentions_of_me: bool,
50+
my_pubkey_hex: &str,
51+
kinds: &[u32],
52+
) -> Result<Vec<serde_json::Value>, CliError> {
53+
if channels.is_empty() && !mentions_of_me {
54+
return Err(CliError::Usage(
55+
"buzz listen requires --channel <UUID> and/or --mentions-of-me".into(),
56+
));
57+
}
58+
for ch in channels {
59+
validate_uuid(ch)?;
60+
}
61+
62+
let mut filters = Vec::new();
63+
if !channels.is_empty() {
64+
filters.push(json!({
65+
"kinds": kinds,
66+
"#h": channels,
67+
}));
68+
}
69+
if mentions_of_me {
70+
let mut f = json!({
71+
"kinds": kinds,
72+
"#p": [my_pubkey_hex],
73+
});
74+
if !channels.is_empty() {
75+
f["#h"] = json!(channels);
76+
}
77+
filters.push(f);
78+
}
79+
Ok(filters)
80+
}
81+
82+
/// Optional POST of each event JSON body to `url` (Option A, best-effort).
83+
async fn post_webhook(client: &reqwest::Client, url: &str, body: &str) {
84+
if let Err(e) = client
85+
.post(url)
86+
.header("content-type", "application/json")
87+
.body(body.to_string())
88+
.send()
89+
.await
90+
{
91+
eprintln!(
92+
"{}",
93+
json!({"error":"webhook","message": format!("{e}")})
94+
);
95+
}
96+
}
97+
98+
fn http_to_ws(http_url: &str) -> String {
99+
http_url
100+
.replace("https://", "wss://")
101+
.replace("http://", "ws://")
102+
}
103+
104+
/// Run the listen loop until Ctrl-C or fatal error.
105+
pub async fn cmd_listen(
106+
client: &BuzzClient,
107+
channels: Vec<String>,
108+
mentions_of_me: bool,
109+
kinds_raw: Option<String>,
110+
webhook: Option<String>,
111+
reconnect: bool,
112+
) -> Result<(), CliError> {
113+
let kinds = parse_kinds(kinds_raw.as_deref())?;
114+
let my_pk = client.keys().public_key().to_hex();
115+
let filters = build_listen_filters(&channels, mentions_of_me, &my_pk, &kinds)?;
116+
let ws_url = http_to_ws(client.relay_url());
117+
let http = reqwest::Client::new();
118+
let running = Arc::new(AtomicBool::new(true));
119+
120+
// Background Ctrl-C watcher (unix + windows via tokio::signal).
121+
{
122+
let r = running.clone();
123+
tokio::spawn(async move {
124+
let _ = tokio::signal::ctrl_c().await;
125+
r.store(false, Ordering::SeqCst);
126+
});
127+
}
128+
129+
let mut backoff_ms: u64 = 500;
130+
const MAX_BACKOFF_MS: u64 = 30_000;
131+
132+
while running.load(Ordering::SeqCst) {
133+
match listen_session(
134+
client,
135+
&ws_url,
136+
&filters,
137+
webhook.as_deref(),
138+
&http,
139+
running.clone(),
140+
)
141+
.await
142+
{
143+
Ok(()) => {
144+
if !running.load(Ordering::SeqCst) {
145+
break;
146+
}
147+
if !reconnect {
148+
break;
149+
}
150+
}
151+
Err(e) => {
152+
if !running.load(Ordering::SeqCst) {
153+
break;
154+
}
155+
if !reconnect {
156+
return Err(e);
157+
}
158+
eprintln!(
159+
"{}",
160+
json!({
161+
"error": "listen_reconnect",
162+
"message": e.to_string(),
163+
"backoff_ms": backoff_ms,
164+
})
165+
);
166+
}
167+
}
168+
if !reconnect || !running.load(Ordering::SeqCst) {
169+
break;
170+
}
171+
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
172+
backoff_ms = (backoff_ms.saturating_mul(2)).min(MAX_BACKOFF_MS);
173+
}
174+
Ok(())
175+
}
176+
177+
async fn listen_session(
178+
client: &BuzzClient,
179+
ws_url: &str,
180+
filters: &[serde_json::Value],
181+
webhook: Option<&str>,
182+
http: &reqwest::Client,
183+
running: Arc<AtomicBool>,
184+
) -> Result<(), CliError> {
185+
use buzz_ws_client::{NostrWsConnection, RelayMessage};
186+
187+
let mut conn = NostrWsConnection::connect_authenticated(
188+
ws_url,
189+
client.keys(),
190+
client.auth_tag(),
191+
)
192+
.await
193+
.map_err(|e| CliError::Other(format!("websocket: {e}")))?;
194+
195+
let sub_id = format!("buzz-listen-{}", &Uuid::new_v4().to_string()[..8]);
196+
let mut req = vec![json!("REQ"), json!(sub_id)];
197+
for f in filters {
198+
req.push(f.clone());
199+
}
200+
conn.send_raw(&json!(req))
201+
.await
202+
.map_err(|e| CliError::Other(format!("websocket send: {e}")))?;
203+
204+
while running.load(Ordering::SeqCst) {
205+
// Short poll so Ctrl-C can exit promptly.
206+
let msg = match conn.next_event(Duration::from_millis(500)).await {
207+
Ok(m) => m,
208+
Err(buzz_ws_client::WsClientError::Timeout) => continue,
209+
Err(e) => return Err(CliError::Other(format!("websocket: {e}"))),
210+
};
211+
212+
match msg {
213+
RelayMessage::Event { event, .. } => {
214+
let raw = serde_json::to_value(event.as_ref())
215+
.map_err(|e| CliError::Other(format!("event serialize: {e}")))?;
216+
let line = normalize_events(std::slice::from_ref(&raw));
217+
// normalize_events returns a JSON array; unwrap first element for NDJSON stream.
218+
let arr: Vec<serde_json::Value> =
219+
serde_json::from_str(&line).unwrap_or_default();
220+
let body = arr.first().cloned().unwrap_or(raw);
221+
let text = serde_json::to_string(&body).unwrap_or_default();
222+
println!("{text}");
223+
let _ = std::io::stdout().flush();
224+
if let Some(url) = webhook {
225+
post_webhook(http, url, &text).await;
226+
}
227+
}
228+
RelayMessage::Closed { message, .. } => {
229+
return Err(CliError::Other(format!("subscription closed: {message}")));
230+
}
231+
RelayMessage::Notice { message } => {
232+
eprintln!("{}", json!({"notice": message}));
233+
}
234+
RelayMessage::Eose { .. }
235+
| RelayMessage::Ok(_)
236+
| RelayMessage::Auth { .. }
237+
| RelayMessage::Count { .. } => {}
238+
}
239+
}
240+
241+
let _ = conn.send_raw(&json!(["CLOSE", sub_id])).await;
242+
let _ = conn.disconnect().await;
243+
Ok(())
244+
}
245+
246+
#[cfg(test)]
247+
mod tests {
248+
use super::*;
249+
250+
#[test]
251+
fn requires_channel_or_mentions() {
252+
let err = build_listen_filters(&[], false, &"a".repeat(64), DEFAULT_KINDS).unwrap_err();
253+
assert!(matches!(err, CliError::Usage(_)));
254+
}
255+
256+
#[test]
257+
fn channel_filter_uses_h_tag() {
258+
let ch = "11111111-1111-1111-1111-111111111111".to_string();
259+
let f = build_listen_filters(&[ch.clone()], false, &"a".repeat(64), DEFAULT_KINDS).unwrap();
260+
assert_eq!(f.len(), 1);
261+
assert_eq!(f[0]["#h"][0], ch);
262+
}
263+
264+
#[test]
265+
fn mentions_filter_uses_p_tag() {
266+
let pk = "b".repeat(64);
267+
let f = build_listen_filters(&[], true, &pk, DEFAULT_KINDS).unwrap();
268+
assert_eq!(f.len(), 1);
269+
assert_eq!(f[0]["#p"][0], pk);
270+
}
271+
272+
#[test]
273+
fn both_channel_and_mentions_two_filters() {
274+
let ch = "11111111-1111-1111-1111-111111111111".to_string();
275+
let pk = "c".repeat(64);
276+
let f = build_listen_filters(&[ch], true, &pk, DEFAULT_KINDS).unwrap();
277+
assert_eq!(f.len(), 2);
278+
}
279+
}

crates/buzz-cli/src/commands/messages.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,13 +334,17 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
334334
crate::OutputFormat::Compact => {
335335
let events: Vec<serde_json::Value> =
336336
serde_json::from_str(normalized).unwrap_or_default();
337+
// Compact is for agent scanning (#2663 gap #2): keep pubkey + tags so
338+
// listeners can skip self-messages and detect `p` mention tags.
337339
let compact: Vec<serde_json::Value> = events
338340
.iter()
339341
.map(|e| {
340342
serde_json::json!({
341-
"id": e.get("id").cloned().unwrap_or_default(),
342-
"content": e.get("content").cloned().unwrap_or_default(),
343-
"created_at": e.get("created_at").cloned().unwrap_or_default(),
343+
"id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
344+
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
345+
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
346+
"created_at": e.get("created_at").cloned().unwrap_or(serde_json::json!(0)),
347+
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
344348
})
345349
})
346350
.collect();
@@ -1373,3 +1377,35 @@ mod tests {
13731377
assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1);
13741378
}
13751379
}
1380+
1381+
1382+
#[cfg(test)]
1383+
mod compact_format_tests {
1384+
use super::format_events;
1385+
use crate::OutputFormat;
1386+
1387+
#[test]
1388+
fn compact_format_keeps_pubkey_and_tags() {
1389+
let normalized = r#"[{"id":"aa","pubkey":"bb","kind":9,"content":"hi","created_at":1,"tags":[["p","cc"],["h","ch"]]}]"#;
1390+
let out = format_events(normalized, &OutputFormat::Compact);
1391+
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1392+
let row = &v[0];
1393+
assert_eq!(row["id"], "aa");
1394+
assert_eq!(row["pubkey"], "bb");
1395+
assert_eq!(row["content"], "hi");
1396+
assert_eq!(row["created_at"], 1);
1397+
assert_eq!(row["tags"][0][0], "p");
1398+
assert_eq!(row["tags"][0][1], "cc");
1399+
assert!(row.get("kind").is_none(), "compact omits kind");
1400+
}
1401+
1402+
#[test]
1403+
fn compact_format_defaults_missing_tags() {
1404+
let normalized = r#"[{"id":"aa","content":"x","created_at":2}]"#;
1405+
let out = format_events(normalized, &OutputFormat::Compact);
1406+
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1407+
// Missing keys become JSON null via Value::default, tags become [].
1408+
assert_eq!(v[0]["pubkey"], "");
1409+
assert_eq!(v[0]["tags"], serde_json::json!([]));
1410+
}
1411+
}

crates/buzz-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod dms;
55
pub mod emoji;
66
pub mod feed;
77
pub mod issues;
8+
pub mod listen;
89
pub mod mem;
910
pub mod messages;
1011
pub mod moderation;

0 commit comments

Comments
 (0)