diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index f6afbf1699..f266ac7292 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -11,8 +11,9 @@ The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default - `local` — hold the key, sign locally, talk to the relay (the unchanged default). - `broker` — keyless; route every operation through a broker host. -This first slice covers the wake→reply loop: `messages get` (with -`--mentions-only`, the wake path) and `messages send` / reply. +Broker mode currently covers: `messages get` (with `--mentions-only`, the wake +path), `messages send` / reply, `reactions add`, and `users set-profile`. Every +other command still needs the local backend. ## Build @@ -46,6 +47,8 @@ CH= buzz messages send --channel "$CH" --content "hello from a keyless client" buzz messages send --channel "$CH" --reply-to --content "on it" buzz messages get --channel "$CH" --mentions-only --limit 10 +buzz reactions add --channel "$CH" --event --emoji "👍" +buzz users set-profile --name "Ada" --about "a keyless agent" ``` Terminal 1 logs each action, the bearer credential, and the args it received. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs index df62beb00a..ce5c5a4b2e 100644 --- a/crates/buzz-cli/examples/mock_broker.rs +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -53,6 +53,16 @@ async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { action, serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), ), + "reaction.add" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 7, "createdAt": 1_700_000_000u64 }), + ), + "profile.set" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 0, "createdAt": 1_700_000_000u64 }), + ), "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), other => serde_json::json!({ "type": "broker_result", diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs index 8658eebfa6..bde4c47b07 100644 --- a/crates/buzz-cli/src/backend.rs +++ b/crates/buzz-cli/src/backend.rs @@ -13,7 +13,8 @@ use uuid::Uuid; use buzz_sdk::broker::{ ActionArgs, ActionOutcome, BrokerClientExt, BrokerError, BrokerRequest, BrokerResult, - ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, PubkeyHex, + ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, + ProfileSetArgs, PubkeyHex, ReactionAddArgs, }; use buzz_sdk::ThreadRef; @@ -23,13 +24,16 @@ use crate::error::CliError; /// The operations an agent performs, in the broker's vocabulary. /// -/// A closed set today — read a channel, post, reply — mirroring the contract's -/// first slice. Adding one is a change here *and* to the contract, deliberately. +/// A closed set — read a channel; post, reply, react; set a profile — mirroring +/// the contract's actions. Adding one is a change here *and* to the contract, +/// deliberately. #[allow(async_fn_in_trait)] // dispatched through the `Backend` enum, never `dyn`. pub trait AgentBackend { async fn channel_read(&self, args: ChannelReadArgs) -> Result; async fn message_post(&self, args: MessagePostArgs) -> Result; async fn message_reply(&self, args: MessageReplyArgs) -> Result; + async fn reaction_add(&self, args: ReactionAddArgs) -> Result; + async fn profile_set(&self, args: ProfileSetArgs) -> Result; } /// Keyless backend: no key, no relay route. Every operation is a broker request. @@ -82,6 +86,20 @@ impl AgentBackend for BrokerBackend { _ => Err(unexpected_outcome("message.reply")), } } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + match self.run(ActionArgs::ReactionAdd(args)).await? { + ActionOutcome::ReactionAdd(published) => Ok(published), + _ => Err(unexpected_outcome("reaction.add")), + } + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + match self.run(ActionArgs::ProfileSet(args)).await? { + ActionOutcome::ProfileSet(published) => Ok(published), + _ => Err(unexpected_outcome("profile.set")), + } + } } /// Local backend: holds the key and talks to the relay directly. Preserves @@ -174,6 +192,40 @@ impl AgentBackend for LocalBackend { let event = self.client.sign_event(builder)?; self.publish(event).await } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + // A kind:7 reaction references only its target event; the channel the + // broker carries is a host-side scoping concept, unused on this path. + let target = nostr::EventId::from_hex(&args.target_event_id) + .map_err(|e| CliError::Other(format!("reaction target: {e}")))?; + let builder = buzz_sdk::build_reaction(target, &args.reaction) + .map_err(|e| CliError::Other(format!("build_reaction: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + // Contract semantics: absent fields are left as they are. With no host to + // merge, emulate it by read-merge-writing over the current kind:0. + let current = crate::commands::users::fetch_current_profile(&self.client).await?; + let get = |key: &str| current.get(key).and_then(|v| v.as_str()).map(str::to_owned); + let display_name = args + .display_name + .or_else(|| get("display_name").or_else(|| get("name"))); + let about = args.about.or_else(|| get("about")); + let picture = args.picture.or_else(|| get("picture")); + let nip05 = get("nip05"); + let builder = buzz_sdk::build_profile( + display_name.as_deref(), + None, + picture.as_deref(), + about.as_deref(), + nip05.as_deref(), + ) + .map_err(|e| CliError::Other(format!("build_profile: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } } /// A runtime-selected backend. Implements [`AgentBackend`] by dispatch, so @@ -220,6 +272,20 @@ impl AgentBackend for Backend { Self::Broker(b) => b.message_reply(args).await, } } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + match self { + Self::Local(b) => b.reaction_add(args).await, + Self::Broker(b) => b.reaction_add(args).await, + } + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + match self { + Self::Local(b) => b.profile_set(args).await, + Self::Broker(b) => b.profile_set(args).await, + } + } } fn broker_verdict(status: &str, error: &BrokerError) -> CliError { @@ -346,6 +412,54 @@ mod tests { assert_eq!(published.event_id, EVENT_ID); } + #[tokio::test] + async fn broker_reaction_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 7, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .reaction_add(ReactionAddArgs { + channel_id: CHANNEL.into(), + target_event_id: EVENT_ID.into(), + reaction: "👍".into(), + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + assert_eq!(published.kind, 7); + } + + #[tokio::test] + async fn broker_profile_set_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 0, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .profile_set(ProfileSetArgs { + display_name: Some("Ada".into()), + about: None, + picture: None, + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + assert_eq!(published.kind, 0); + } + #[tokio::test] async fn broker_read_returns_a_page_of_signed_events() { // A real signed event, so the strict event reader accepts it. diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d30131..11d4b979f7 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use nostr::EventId; +use buzz_sdk::broker::ReactionAddArgs; + +use crate::backend::{AgentBackend, Backend}; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -131,8 +134,52 @@ pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<( event, emoji, emoji_url, + channel: _, // relay-side reactions reference only the target event } => 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, } } + +/// Keyless (broker) dispatch for the reactions group: `reactions add` only. +pub async fn dispatch_broker(cmd: crate::ReactionsCmd, backend: &Backend) -> Result<(), CliError> { + use crate::ReactionsCmd; + match cmd { + ReactionsCmd::Add { + event, + emoji, + emoji_url, + channel, + } => { + if emoji_url.is_some() { + return Err(CliError::Usage( + "--emoji-url is not supported in keyless mode; the contract's reaction.add \ + carries an emoji or a :shortcode: string and the host owns custom emoji" + .into(), + )); + } + let channel_id = channel.ok_or_else(|| { + CliError::Usage( + "--channel is required in keyless mode (the host scopes the reaction to it)" + .into(), + ) + })?; + let published = backend + .reaction_add(ReactionAddArgs { + channel_id, + target_event_id: event, + reaction: emoji, + }) + .await?; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode supports only 'reactions add' in this group".into(), + )), + } +} diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index bb2d45dbf1..59ec6101c0 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,6 +1,8 @@ use buzz_core::kind::KIND_MANAGED_AGENT; +use buzz_sdk::broker::ProfileSetArgs; use nostr::PublicKey; +use crate::backend::{AgentBackend, Backend}; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -422,9 +424,51 @@ pub async fn cmd_set_profile( Ok(()) } +/// Keyless (broker) dispatch for the users group: `set-profile` only. +pub async fn dispatch_broker(cmd: crate::UsersCmd, backend: &Backend) -> Result<(), CliError> { + use crate::UsersCmd; + match cmd { + UsersCmd::SetProfile { + name, + avatar, + about, + nip05, + } => { + if nip05.is_some() { + return Err(CliError::Usage( + "--nip05 is not supported in keyless mode; the contract's profile.set carries \ + display name, about, and picture only" + .into(), + )); + } + if name.is_none() && avatar.is_none() && about.is_none() { + return Err(CliError::Usage( + "include at least one profile field to set (--name, --about, --avatar)".into(), + )); + } + let published = backend + .profile_set(ProfileSetArgs { + display_name: name, + about, + picture: avatar, + }) + .await?; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode supports only 'users set-profile' in this group".into(), + )), + } +} + /// 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( +pub(crate) async fn fetch_current_profile( client: &BuzzClient, ) -> Result, CliError> { let my_pk = client.keys().public_key().to_hex(); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 74a2ead843..4121c93028 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -778,6 +778,9 @@ pub enum ReactionsCmd { /// Image URL for a custom emoji reaction; when set, content becomes `:shortcode:` #[arg(long = "emoji-url")] emoji_url: Option, + /// Channel UUID — required in keyless mode (the host scopes the reaction to it) + #[arg(long)] + channel: Option, }, /// Remove an emoji reaction from a message Remove { @@ -2171,10 +2174,12 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { match cli.command { Cmd::Messages(sub) => commands::messages::dispatch_broker(sub, &backend).await, + Cmd::Reactions(sub) => commands::reactions::dispatch_broker(sub, &backend).await, + Cmd::Users(sub) => commands::users::dispatch_broker(sub, &backend).await, _ => Err(CliError::Usage( - "keyless (broker) mode currently supports only 'messages get' and 'messages send' \ - (the wake→reply slice); other commands need the local backend — unset --agent-mode \ - or set it to 'local'" + "keyless (broker) mode currently supports the wake→reply slice plus reactions and \ + profile: 'messages get/send', 'reactions add', and 'users set-profile'. Other \ + commands need the local backend — unset --agent-mode or set it to 'local'" .into(), )), }