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
5 changes: 4 additions & 1 deletion NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. |
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. A reply should also mark why each `p` tag is there: `["p","<pubkey>","","reply"]` for the author being answered and `["p","<pubkey>","","mention"]` for someone typed as `@name`. Both are accepted bare, but an unmarked `p` tag is byte-identical to a typed mention, so the recipient has to fetch the parent to tell them apart — and where that lookup cannot answer, the tag reads as a mention, piercing their mute. |
| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. |
| **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. |
| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. |
Expand Down Expand Up @@ -180,8 +180,11 @@ nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
--auth --sec <privkey> ws://localhost:3000

# Reply to a message (NIP-10 threading)
# The `p` tag marked `reply` names the author being answered — omit the marker
# and the recipient must fetch the parent to tell it from a typed @mention.
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
--tag "e=<parent-event-id>;;reply" \
--tag "p=<parent-author-pubkey>;;reply" \
--auth --sec <privkey> ws://localhost:3000

# Fetch gift-wrapped DMs (NIP-17)
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4560,6 +4560,9 @@ pub(crate) async fn post_failure_notice(
Some(buzz_sdk::ThreadRef {
root_event_id: root_id,
parent_event_id: parent_id,
// A failure notice addresses the channel, not a person, and the
// parsed thread tags do not carry the parent's author anyway.
parent_author: None,
})
});
let builder =
Expand Down
104 changes: 79 additions & 25 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,46 +588,52 @@ async fn handle_setup_membership(
}
}

/// Build and publish a setup nudge reply to the triggering event.
/// Thread placement for a setup nudge answering `triggering_event`.
///
/// Threading: flat reply to the thread root if one exists; otherwise reply
/// to the triggering event itself. P-tags the asker.
/// The nudge replies to the triggering event, rooted at that event's thread when
/// it has one. `parent_event_id` and `parent_author` must describe the same
/// event: receivers read the addressing marker on the asker's `p` tag as "this
/// answers something I wrote", so pointing the `e` tag at the thread root while
/// naming the triggering event's author made that claim false for anyone who
/// asked inside somebody else's thread.
fn nudge_thread_ref(triggering_event: &nostr::Event) -> Result<buzz_sdk::ThreadRef> {
use buzz_sdk::ThreadRef;

let thread_tags = crate::queue::parse_thread_tags(triggering_event);
let root_event_id = match &thread_tags.root_event_id {
Some(root_str) => nostr::EventId::from_hex(root_str)
.map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?,
// Top-level event: it is its own thread root.
None => triggering_event.id,
};

Ok(ThreadRef {
root_event_id,
parent_event_id: triggering_event.id,
// The asker. Carried here, marked as addressing, rather than passed as a
// mention: the nudge answers them, it does not `@` them, and a bare `p`
// tag cannot say which.
parent_author: Some(triggering_event.pubkey),
})
}

/// Build and publish a setup nudge reply to the triggering event.
async fn publish_setup_nudge(
publisher: &RelayEventPublisher,
keys: &nostr::Keys,
channel_id: Uuid,
triggering_event: &nostr::Event,
payload: &SetupPayload,
) -> Result<()> {
use buzz_sdk::ThreadRef;

// Parse NIP-10 thread tags to determine reply target.
let thread_tags = crate::queue::parse_thread_tags(triggering_event);

let thread_ref = if let Some(root_str) = &thread_tags.root_event_id {
// Threaded event: reply flat to the root.
let root_id = nostr::EventId::from_hex(root_str)
.map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?;
Some(ThreadRef {
root_event_id: root_id,
parent_event_id: root_id,
})
} else {
// Top-level event: reply to the triggering event.
Some(ThreadRef {
root_event_id: triggering_event.id,
parent_event_id: triggering_event.id,
})
};
let thread_ref = Some(nudge_thread_ref(triggering_event)?);

let body = payload.nudge_body();
let author_hex = triggering_event.pubkey.to_hex();

let event_builder = buzz_sdk::build_message(
channel_id,
&body,
thread_ref.as_ref(),
&[&author_hex], // p-tag the asker
&[], // the asker is p-tagged via `thread_ref.parent_author`
false,
&[],
)
Expand All @@ -651,6 +657,54 @@ async fn publish_setup_nudge(
mod tests {
use super::*;

/// Sign an event carrying the given tags, as the triggering ask would arrive.
fn asked(tags: Vec<Vec<&str>>) -> nostr::Event {
let keys = nostr::Keys::generate();
nostr::EventBuilder::new(nostr::Kind::Custom(9), "@eva do the thing")
.tags(tags.into_iter().map(|t| nostr::Tag::parse(t).unwrap()))
.sign_with_keys(&keys)
.unwrap()
}

#[test]
fn a_nudge_answers_the_ask_it_replies_to_not_the_thread_root() {
// The ask is a reply inside someone else's thread. `parent_author` names
// the asker, so `parent_event_id` has to be the asker's event: the
// addressing marker on the resulting `p` tag tells receivers "this
// answers something you wrote", and naming the root would make that
// false for everyone but the root's author.
// Distinct ids, or `root_event_id` would match whichever tag the code
// happened to read and the assertion below would prove nothing.
let root = nostr::EventId::from_hex(&format!("{:064x}", 0xd00du64)).unwrap();
let answered = nostr::EventId::from_hex(&format!("{:064x}", 0xa11cu64)).unwrap();
let ask = asked(vec![
vec!["h", "5e0f6b1c-0000-4000-8000-000000000000"],
vec!["e", &root.to_hex(), "", "root"],
vec!["e", &answered.to_hex(), "", "reply"],
]);

let tr = nudge_thread_ref(&ask).unwrap();
assert_eq!(
tr.root_event_id, root,
"the nudge stays in the ask's thread"
);
assert_eq!(
tr.parent_event_id, ask.id,
"the nudge replies to the ask, not to the root"
);
assert_eq!(tr.parent_author, Some(ask.pubkey));
}

#[test]
fn a_nudge_to_a_top_level_ask_roots_the_thread_at_that_ask() {
let ask = asked(vec![vec!["h", "5e0f6b1c-0000-4000-8000-000000000000"]]);

let tr = nudge_thread_ref(&ask).unwrap();
assert_eq!(tr.root_event_id, ask.id);
assert_eq!(tr.parent_event_id, ask.id);
assert_eq!(tr.parent_author, Some(ask.pubkey));
}

#[test]
fn setup_payload_from_raw_returns_none_when_absent() {
// None → Ok(None): normal startup, no setup payload.
Expand Down
90 changes: 79 additions & 11 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
.map(|(root, _)| root)
}

/// Drop the parent author when it is this identity: a reply to one's own
/// message must not carry a self `p` tag, which would put it in the author's
/// own `#p` mention feed.
fn parent_author_excluding_self(client: &BuzzClient, author: Option<String>) -> Option<String> {
let self_hex = client.keys().public_key().to_hex();
author.filter(|pubkey| !pubkey.eq_ignore_ascii_case(&self_hex))
}

/// The pubkey of the event a reply answers.
///
/// A reply must `p`-tag it: agent `require_mention` subscriptions are `#p` REQ
/// filters, so an untagged reply is never transmitted to the agent being
/// answered.
fn parent_author_from_event(event: &serde_json::Value) -> Option<String> {
event
.get("pubkey")
.and_then(serde_json::Value::as_str)
.filter(|pubkey| pubkey.len() == 64)
.map(str::to_string)
}

fn thread_ref_from_parent_tags(
parent_eid: nostr::EventId,
parent_event_id: &str,
Expand All @@ -52,6 +73,8 @@ fn thread_ref_from_parent_tags(
Ok(ThreadRef {
root_event_id: root_eid,
parent_event_id: parent_eid,
// Filled in by the callers, which drop self first.
parent_author: None,
})
}

Expand All @@ -75,12 +98,14 @@ async fn fetch_event(client: &BuzzClient, event_id: &str) -> Result<serde_json::
.ok_or_else(|| CliError::NotFound(format!("event {event_id} not found")))
}

/// Resolve a reply's thread position and the pubkey of the event it answers.
async fn resolve_thread_ref(
client: &BuzzClient,
parent_event_id: &str,
) -> Result<ThreadRef, CliError> {
) -> Result<(ThreadRef, Option<String>), CliError> {
let event = fetch_event(client, parent_event_id).await?;
thread_ref_from_event(parent_event_id, &event)
let thread_ref = thread_ref_from_event(parent_event_id, &event)?;
Ok((thread_ref, parent_author_from_event(&event)))
}

fn thread_ref_from_event(event_id: &str, event: &serde_json::Value) -> Result<ThreadRef, CliError> {
Expand Down Expand Up @@ -672,12 +697,42 @@ pub async fn cmd_send_message(

// 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
let (thread_ref, parent_author) = match p.reply_to {
Some(ref r) => {
let (tr, author) = resolve_thread_ref(client, r).await?;
(Some(tr), author)
}
None => (None, None),
};

// A reply addresses the author it answers (NIP-10). Resolved after the
// membership check above so answering someone who has since left the
// channel still works, and carried on the thread ref rather than folded
// into the mentions: as a bare `p` tag the two roles are indistinguishable,
// and the receiver has to fetch the parent to guess which one this is. Self
// is dropped — our own reply must not land in our own mention feed.
let mut mention_pubkeys = mention_pubkeys;
let mut thread_ref = thread_ref;
if let (Some(tr), Some(author)) = (
thread_ref.as_mut(),
parent_author_excluding_self(client, parent_author),
) {
let already_typed = mention_pubkeys
.iter()
.any(|pk| pk.eq_ignore_ascii_case(&author));
if !already_typed {
// `merge_message_mentions` has already truncated to MENTION_CAP, so
// the addressing tag would put the list one over and the builder
// rejects the whole send. That tag is the one that must survive —
// without it the agent being answered never receives the reply — so
// drop a body mention to make room rather than failing.
if mention_pubkeys.len() >= buzz_sdk::mentions::MENTION_CAP {
mention_pubkeys.truncate(buzz_sdk::mentions::MENTION_CAP - 1);
}
tr.parent_author = nostr::PublicKey::from_hex(&author).ok();
}
}

let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();

let builder = match p.kind {
Expand Down Expand Up @@ -782,10 +837,14 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re

// `--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
// A diff reply addresses the author it answers, same as any other reply:
// an agent's `require_mention` subscription is a `#p` REQ filter.
let (thread_ref, parent_author) = match &p.reply_to {
Some(r) => {
let (tr, author) = resolve_thread_ref(client, r).await?;
(Some(tr), author)
}
None => (None, None),
};

let branch = match (&p.source_branch, &p.target_branch) {
Expand All @@ -806,8 +865,17 @@ pub async fn cmd_send_diff_message(client: &BuzzClient, p: SendDiffParams) -> Re
alt_text: Some(alt),
};

// Carried on the thread ref, and marked, so the receiver does not have to
// fetch the parent to learn this `p` tag is addressing and not a mention.
let mut thread_ref = thread_ref;
if let (Some(tr), Some(author)) = (
thread_ref.as_mut(),
parent_author_excluding_self(client, parent_author),
) {
tr.parent_author = nostr::PublicKey::from_hex(&author).ok();
}
let builder =
buzz_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref())
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)?;
Expand Down
Loading