keyless: add reaction.add and profile.set to broker mode

Extend the AgentBackend seam with reaction_add / profile_set and route
two more command groups through it in keyless mode:

- reactions add  -> reaction.add  (requires --channel, the host's scope;
  refuses --emoji-url, which is host-owned custom-emoji handling)
- users set-profile -> profile.set (maps name/about/avatar; refuses
  --nip05, absent from the contract; requires >=1 field)

BrokerBackend maps each to its ActionArgs and unwraps the EventPublished
outcome, same as message.post. LocalBackend implements both for parity
(reaction via build_reaction; profile as read-merge-write over the
current kind:0, emulating the contract's 'absent fields left as-is').
Local command paths are untouched. Mock host and KEYLESS.md gain the two
actions; two BrokerBackend round-trip tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <jrobotham@squareup.com>
This commit is contained in:
Joel Robotham
2026-08-27 11:41:30 +10:00
parent de40dc85c5
commit 82ac33285a
6 changed files with 232 additions and 9 deletions

View File

@@ -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=<channel-uuid>
buzz messages send --channel "$CH" --content "hello from a keyless client"
buzz messages send --channel "$CH" --reply-to <event-id> --content "on it"
buzz messages get --channel "$CH" --mentions-only --limit 10
buzz reactions add --channel "$CH" --event <event-id> --emoji "👍"
buzz users set-profile --name "Ada" --about "a keyless agent"
```
Terminal 1 logs each action, the bearer credential, and the args it received.

View File

@@ -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",

View File

@@ -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<MessagePage, CliError>;
async fn message_post(&self, args: MessagePostArgs) -> Result<EventPublished, CliError>;
async fn message_reply(&self, args: MessageReplyArgs) -> Result<EventPublished, CliError>;
async fn reaction_add(&self, args: ReactionAddArgs) -> Result<EventPublished, CliError>;
async fn profile_set(&self, args: ProfileSetArgs) -> Result<EventPublished, CliError>;
}
/// 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<EventPublished, CliError> {
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<EventPublished, CliError> {
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<EventPublished, CliError> {
// 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<EventPublished, CliError> {
// 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<EventPublished, CliError> {
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<EventPublished, CliError> {
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.

View File

@@ -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(),
)),
}
}

View File

@@ -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<serde_json::Map<String, serde_json::Value>, CliError> {
let my_pk = client.keys().public_key().to_hex();

View File

@@ -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<String>,
/// Channel UUID — required in keyless mode (the host scopes the reaction to it)
#[arg(long)]
channel: Option<String>,
},
/// 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(),
)),
}