Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,10 @@ async fn tokio_main() -> Result<()> {
// buzz_event.event (needed for mode gate below).
let author_hex = buzz_event.event.pubkey.to_hex();
let event_id_hex = buzz_event.event.id.to_hex();
// Captured before `buzz_event.event` moves into the
// queue; the πŸ‘€ reaction below needs it for its
// NIP-25 `p` tag.
let event_author = buzz_event.event.pubkey;
// Clone for the non-cancelling steer fork, which
// needs the event to render the steer body. The
// clone is unconditional because we don't know
Expand Down Expand Up @@ -2155,7 +2159,7 @@ async fn tokio_main() -> Result<()> {
let rc = ctx.rest_client.clone();
let eid = event_id_hex.clone();
tokio::spawn(async move {
pool::reaction_add(&rc, &eid, "πŸ‘€").await;
pool::reaction_add(&rc, &eid, event_author, "πŸ‘€").await;
});
}
// Event is already queued. If mode requires it AND
Expand Down
64 changes: 51 additions & 13 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,14 +1289,30 @@ pub async fn run_prompt_task(
let liveness_handle = tokio::spawn(liveness);
let liveness_guard = LivenessGuard::new(liveness_handle, liveness_state);

// Collects event IDs up front. On drop (any exit path β€” normal, early
// return, or panic), spawns best-effort cleanup of both πŸ‘€ and πŸ’¬.
// Collects reaction targets up front. On drop (any exit path β€” normal,
// early return, or panic), spawns best-effort cleanup of both πŸ‘€ and πŸ’¬.
// See `ReactionGuard` docs for ordering guarantees and known edge cases.
let reaction_ids: Vec<String> = batch
let reaction_targets: Vec<ReactionTarget> = batch
.as_ref()
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
.map(|b| {
b.events
.iter()
.map(|be| ReactionTarget {
event_id: be.event.id.to_hex(),
author: be.event.pubkey,
})
.collect()
})
.unwrap_or_default();
let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone());
// Removal is a kind:5 delete keyed on the reaction's own id, so cleanup
// only ever needs the target event ids.
let _reaction_guard = ReactionGuard::new(
ctx.rest_client.clone(),
reaction_targets
.iter()
.map(|t| t.event_id.clone())
.collect(),
);

//
// Core memory is delivered inside the system prompt the harness already
Expand Down Expand Up @@ -1755,11 +1771,11 @@ pub async fn run_prompt_task(
// πŸ’¬ β€” fire-and-forget so the prompt fires immediately.
// The guard's cleanup (spawned on drop) removes πŸ’¬ after the turn completes.
// A brief race where πŸ’¬ appears slightly after the agent starts is acceptable.
if !reaction_ids.is_empty() {
if !reaction_targets.is_empty() {
let rest = ctx.rest_client.clone();
let ids = reaction_ids.clone();
let targets = reaction_targets.clone();
tokio::spawn(async move {
react_working(&rest, &ids).await;
react_working(&rest, &targets).await;
});
}

Expand Down Expand Up @@ -3422,15 +3438,24 @@ fn pct_encode(s: &str) -> String {
/// Builds a reaction event with `buzz_sdk::build_reaction`, signs it with
/// the keys already stored in `RestClient`, and submits via `POST /events`.
/// Returns immediately on timeout or any error β€” reactions are cosmetic.
pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) {
///
/// `target_author` is the pubkey of the event being reacted to; it becomes the
/// NIP-25 `p` tag. The agent's πŸ‘€/πŸ’¬ indicators are reactions like any other,
/// so they carry it too rather than emitting a non-conformant kind:7.
pub(crate) async fn reaction_add(
rest: &crate::relay::RestClient,
event_id: &str,
target_author: nostr::PublicKey,
emoji: &str,
) {
let target_id = match nostr::EventId::from_hex(event_id) {
Ok(id) => id,
Err(e) => {
tracing::debug!(event_id, emoji, "reaction add: invalid event ID: {e}");
return;
}
};
let builder = match buzz_sdk::build_reaction(target_id, emoji) {
let builder = match buzz_sdk::build_reaction(target_id, target_author, emoji) {
Ok(b) => b,
Err(e) => {
tracing::warn!(event_id, emoji, "reaction add: build failed: {e}");
Expand Down Expand Up @@ -3580,14 +3605,27 @@ pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &
/// Prevents unbounded parallelism when a large batch of events arrives.
const REACTION_CONCURRENCY: usize = 10;

/// An event an agent status reaction is attached to.
///
/// Adding a reaction needs the target's author for the NIP-25 `p` tag;
/// removing one does not (a kind:5 delete keys off the reaction's own id).
#[derive(Clone)]
pub(crate) struct ReactionTarget {
pub(crate) event_id: String,
pub(crate) author: nostr::PublicKey,
}

/// Add πŸ’¬ to all events, capped at `REACTION_CONCURRENCY` concurrent requests.
/// Awaited inline before the prompt fires.
async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) {
for chunk in event_ids.chunks(REACTION_CONCURRENCY) {
///
/// Targets are `(event_id, author)` pairs β€” the author rides along so each
/// kind:7 can carry its NIP-25 `p` tag.
async fn react_working(rest: &crate::relay::RestClient, targets: &[ReactionTarget]) {
for chunk in targets.chunks(REACTION_CONCURRENCY) {
futures_util::future::join_all(
chunk
.iter()
.map(|eid| reaction_add(rest, eid, REACTION_WORKING)),
.map(|t| reaction_add(rest, &t.event_id, t.author, REACTION_WORKING)),
)
.await;
}
Expand Down
31 changes: 29 additions & 2 deletions crates/buzz-cli/src/commands/reactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;

/// Look up the author of a reaction's target event.
///
/// NIP-25 requires the reaction to carry the target author's `p` tag, and the
/// CLI is only given an event id β€” so resolve the author from the relay. The
/// relay rejects reactions whose target it cannot find, so a target that does
/// not resolve here would have been rejected on submit anyway; failing at this
/// point just produces a clearer message.
async fn fetch_reaction_target_author(
client: &BuzzClient,
event_id: &str,
) -> Result<nostr::PublicKey, CliError> {
let raw = client
.query(&serde_json::json!({ "ids": [event_id], "limit": 1 }))
.await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse target event query: {e}")))?;
let author = events
.as_array()
.and_then(|arr| arr.first())
.and_then(|ev| ev.get("pubkey"))
.and_then(|pk| pk.as_str())
.ok_or_else(|| CliError::Other(format!("reaction target event {event_id} not found")))?;
nostr::PublicKey::parse(author)
.map_err(|e| CliError::Other(format!("target event has an invalid pubkey: {e}")))
}

pub async fn cmd_add_reaction(
client: &BuzzClient,
event_id: &str,
Expand All @@ -15,12 +41,13 @@ pub async fn cmd_add_reaction(
validate_hex64(event_id)?;
let target_eid =
EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?;
let target_author = fetch_reaction_target_author(client, event_id).await?;

let builder = if let Some(url) = emoji_url {
buzz_sdk::build_custom_emoji_reaction(target_eid, emoji, url)
buzz_sdk::build_custom_emoji_reaction(target_eid, target_author, emoji, url)
.map_err(|e| CliError::Other(format!("build_custom_emoji_reaction failed: {e}")))?
} else {
buzz_sdk::build_reaction(target_eid, emoji)
buzz_sdk::build_reaction(target_eid, target_author, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?
};

Expand Down
66 changes: 57 additions & 9 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,24 +460,43 @@ pub fn build_vote(
}

/// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars.
///
/// `target_author` is the pubkey of the event being reacted to. NIP-25 requires
/// the reaction to carry the target's `p` tag; it is what lets the author (and
/// any NIP-01 client) find the reaction with a `{"kinds":[7],"#p":[<self>]}`
/// notification filter. Omitting it makes the reaction invisible to every
/// notification surface, so it is a required argument rather than an option.
///
/// `.allow_self_tagging()` is required: reacting to your own message makes the
/// `p` tag match the signer, and nostr 0.44 strips matching `p` tags by
/// default. Without it, self-reactions would silently regress to the
/// no-`p`-tag wire form.
pub fn build_reaction(
target_event_id: nostr::EventId,
target_author: nostr::PublicKey,
emoji: &str,
) -> Result<EventBuilder, SdkError> {
if emoji.chars().count() > 64 {
return Err(SdkError::EmojiTooLong);
}
let tags = vec![tag(&["e", &target_event_id.to_hex()])?];
Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags))
let tags = vec![
tag(&["e", &target_event_id.to_hex()])?,
tag(&["p", &target_author.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(7), emoji)
.tags(tags)
.allow_self_tagging())
}

/// Build a NIP-25 reaction event using a NIP-30 custom emoji.
///
/// The reaction content is `:shortcode:` and the event carries exactly one
/// `["emoji", shortcode, url]` tag, matching NIP-25's custom emoji reaction
/// guidance.
/// guidance. `target_author` carries the same NIP-25 `p` tag as
/// [`build_reaction`], including its `.allow_self_tagging()` requirement.
pub fn build_custom_emoji_reaction(
target_event_id: nostr::EventId,
target_author: nostr::PublicKey,
shortcode: &str,
url: &str,
) -> Result<EventBuilder, SdkError> {
Expand All @@ -486,9 +505,12 @@ pub fn build_custom_emoji_reaction(
let content = format!(":{shortcode}:");
let tags = vec![
tag(&["e", &target_event_id.to_hex()])?,
tag(&["p", &target_author.to_hex()])?,
tag(&["emoji", &shortcode, url])?,
];
Ok(EventBuilder::new(Kind::Custom(7), content).tags(tags))
Ok(EventBuilder::new(Kind::Custom(7), content)
.tags(tags)
.allow_self_tagging())
}

/// Build a deletion event targeting a reaction (kind 5).
Expand Down Expand Up @@ -2244,17 +2266,36 @@ mod tests {
#[test]
fn reaction_happy_path() {
let eid = event_id();
let ev = sign(build_reaction(eid, "πŸ‘").unwrap());
let author = keys().public_key();
let ev = sign(build_reaction(eid, author, "πŸ‘").unwrap());
assert_eq!(ev.kind.as_u16(), 7);
assert_eq!(ev.content, "πŸ‘");
assert!(has_tag(&ev, "e", &eid.to_hex()));
// NIP-25: the reaction MUST carry the target author's `p` tag so
// `{"kinds":[7],"#p":[author]}` notification filters match.
assert!(has_tag(&ev, "p", &author.to_hex()));
}

#[test]
fn reaction_keeps_p_tag_when_reacting_to_own_message() {
// Self-reaction: the `p` tag equals the signer, which nostr 0.44
// scrubs unless `.allow_self_tagging()` is set. Pins that the p tag
// survives so a self-reaction is still NIP-25 conformant.
let k = keys();
let eid = event_id();
let ev = build_reaction(eid, k.public_key(), "πŸ‘")
.unwrap()
.sign_with_keys(&k)
.expect("sign");
assert!(has_tag(&ev, "p", &k.public_key().to_hex()));
}

#[test]
fn reaction_emoji_too_long() {
let eid = event_id();
let long_emoji = "a".repeat(65);
assert!(matches!(
build_reaction(eid, &long_emoji),
build_reaction(eid, keys().public_key(), &long_emoji),
Err(SdkError::EmojiTooLong)
));
}
Expand All @@ -2263,19 +2304,26 @@ mod tests {
fn reaction_emoji_max_len_ok() {
let eid = event_id();
let max_emoji = "a".repeat(64);
assert!(build_reaction(eid, &max_emoji).is_ok());
assert!(build_reaction(eid, keys().public_key(), &max_emoji).is_ok());
}

#[test]
fn custom_emoji_reaction_happy_path() {
let eid = event_id();
let author = keys().public_key();
let ev = sign(
build_custom_emoji_reaction(eid, ":Party_Parrot:", "https://example.com/parrot.png")
.unwrap(),
build_custom_emoji_reaction(
eid,
author,
":Party_Parrot:",
"https://example.com/parrot.png",
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 7);
assert_eq!(ev.content, ":party_parrot:");
assert!(has_tag(&ev, "emoji", "party_parrot"));
assert!(has_tag(&ev, "p", &author.to_hex()));
}

#[test]
Expand Down
17 changes: 14 additions & 3 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,21 +816,32 @@ pub async fn send_managed_agent_channel_message(
})
}

/// `target_pubkey` is the *displayed* author of the reacted-to message β€” the
/// pubkey the timeline resolved via `resolveEventAuthorPubkey`, not the raw
/// signer. For relay-signed agent messages those differ, and the person who
/// should see the notification is the resolved author. Callers already hold
/// this on the message they are reacting to, so it is passed in rather than
/// re-derived here from a second relay round trip.
#[tauri::command]
pub async fn add_reaction(
event_id: String,
emoji: String,
emoji_url: Option<String>,
target_pubkey: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?;
let target_author = PublicKey::from_hex(target_pubkey.trim())
.map_err(|e| format!("invalid target pubkey: {e}"))?;
let builder = match emoji_url {
// Custom-emoji reaction (NIP-30): kind:7 with `:shortcode:` content and
// an `["emoji", shortcode, url]` tag. Delegates to the SDK builder so
// shortcode normalization + validation match the relay exactly.
Some(url) => buzz_sdk_pkg::build_custom_emoji_reaction(target_eid, emoji.trim(), &url)
.map_err(|e| format!("invalid custom emoji reaction: {e}"))?,
None => events::build_reaction(target_eid, emoji.trim())?,
Some(url) => {
buzz_sdk_pkg::build_custom_emoji_reaction(target_eid, target_author, emoji.trim(), &url)
.map_err(|e| format!("invalid custom emoji reaction: {e}"))?
}
None => events::build_reaction(target_eid, target_author, emoji.trim())?,
};
submit_event(builder, &state).await?;
Ok(())
Expand Down
23 changes: 20 additions & 3 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,14 +443,31 @@ pub fn build_delete_compat(
// ── Reactions ────────────────────────────────────────────────────────────────

/// Kind 7 β€” NIP-25 reaction.
pub fn build_reaction(target_event_id: EventId, emoji: &str) -> Result<EventBuilder, String> {
///
/// `target_author` is the pubkey of the reacted-to event. NIP-25 requires the
/// `p` tag: it is what makes the reaction visible to a
/// `{"kinds":[7],"#p":[<self>]}` notification filter (the shape the relay's
/// push-lease surface advertises for kind 7).
///
/// `.allow_self_tagging()` is required: reacting to your own message makes the
/// `p` tag match the signer, and nostr strips matching `p` tags by default.
pub fn build_reaction(
target_event_id: EventId,
target_author: nostr::PublicKey,
emoji: &str,
) -> Result<EventBuilder, String> {
if emoji.chars().count() > MAX_EMOJI_CHARS {
return Err(format!(
"emoji exceeds maximum length of {MAX_EMOJI_CHARS} characters"
));
}
let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?];
Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags))
let tags = vec![
tag(vec!["e", &target_event_id.to_hex()])?,
tag(vec!["p", &target_author.to_hex()])?,
];
Ok(EventBuilder::new(Kind::Custom(7), emoji)
.tags(tags)
.allow_self_tagging())
}

/// Kind 5 β€” delete a reaction event.
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,16 @@ export function useChannelPaneHandlers({
);

const handleToggleReaction = React.useCallback(
async (message: { id: string }, emoji: string, remove: boolean) => {
async (
message: { id: string; pubkey?: string },
emoji: string,
remove: boolean,
) => {
await toggleMutateRef.current({
emoji,
eventId: message.id,
remove,
targetPubkey: message.pubkey,
});
},
[],
Expand Down
Loading