Files
buzz/crates/buzz-core/src/nip10.rs
Will Pfleger c6e3fe7dfa feat(workflows): reply in-thread from send_message action (#6178)
The `send_message` workflow action could only post new top-level channel
messages. This adds a `reply_in_thread` option so a `message_posted`
workflow can reply in the triggering message's thread, plus a
`trigger_is_reply` filter variable so a workflow can fire only on
top-level messages.

Every NIP-10 thread-marker reader in the tree now routes through one
shared parser and one collapse rule in `buzz-core`, deleting four
hand-rolled copies that had drifted on id-validity and marker semantics.

## What changed

- **Schema** (`crates/buzz-workflow/src/schema.rs`): new
`reply_in_thread: bool` (serde default `false`) on
`ActionDef::SendMessage`. `validate()` rejects `reply_in_thread: true`
on `schedule`/`webhook` triggers — they carry no message to reply to.
- **Executor** (`crates/buzz-workflow/src/executor.rs`): threads
`reply_to: Option<&str>` (the trigger `message_id`) through
`ActionSink::send_message` when `reply_in_thread` is set; errors clearly
if the trigger has no `message_id`. `resolve_step_templates` carries the
new field. Adds `trigger_is_reply` (boolean) to the eval context.
- **Relay sink** (`crates/buzz-relay/src/workflow_sink.rs`): when
`reply_to` is set, resolves parent/root/depth from the known trigger
event, persists real thread metadata instead of the hardcoded top-level
`depth: 0`, and pushes the live kind:39005 thread-summary overlay after
insert so subscribed desktops update the root's reply badge without
refetching — matching the human ingest path. Emits the same NIP-10
`e`-tag shape as `buzz_sdk::builders::thread_tags`: a single `["e", id,
"", "reply"]` tag for a direct reply (parent == root), and the `root` +
`reply` pair only when nested — so every writer produces one wire shape
per reply kind.
- **Ingest resolver** (`crates/buzz-relay/src/handlers/ingest.rs`): new
`resolve_relay_reply_thread_meta` + `ReplyAncestry`. Unlike the
client-facing `resolve_nip10_thread_meta` (which validates
client-supplied tags), this computes root/depth from the known parent
and enforces the same same-channel and depth-limit invariants. When the
parent has no `thread_metadata` row, both resolvers share
`derive_ancestry_from_parent_tags`, which applies
`ThreadMarkers::resolve()`: a marked nested parent remains depth 2,
while a root-only, malformed, or unmarked parent is top-level and starts
its reply thread at itself. Both the client resolver and the
parent-ancestor fallback now read markers via the shared parser instead
of hand-scanning `e`-tags.
- **Shared NIP-10 parser** (`crates/buzz-core/src/nip10.rs`):
`parse_thread_markers` reads an event's `root`/`reply` markers, honoring
a marker only when its event id is exactly 64 ASCII-hex characters.
`ThreadMarkers::resolve()` is the single definition of the (root, reply)
→ (root_id, parent_id) collapse: `root`+`reply` as-is, `reply`-only →
`(reply, reply)`, a lone `root` or neither → top-level. A slice-based
`parse_thread_markers_from_parts` entry point serves consumers holding
raw JSON tag arrays. Relay ingest, the workflow `trigger_is_reply`
predicate, ACP anchoring, and the CLI reply resolver all call these, so
they cannot drift on marker, id-validity, or collapse semantics.
- **ACP** (`crates/buzz-acp/src/queue.rs`): `parse_thread_tags`
delegates marker parsing and collapse to `buzz_core::nip10`, keeping
only its local `p`-tag mention collection. This fixes a parity gap with
ingest: a malformed non-64-hex marker id is no longer counted as a
thread link, and a lone `root` marker is now top-level rather than being
treated as root == parent.
- **CLI** (`crates/buzz-cli/src/commands/messages.rs`):
`find_root_from_tags` routes its JSON tags through the shared slice
parser and collapse rule. CLI replies to root-only, malformed, or
unmarked parents now correctly start at that immediate parent;
reply-only and root+reply parent ancestry remain intact.
- **Trigger context** (`crates/buzz-workflow/src/lib.rs`):
`build_trigger_context` derives `is_reply` solely from a valid NIP-10
`reply` marker (no DB hit). A lone `root` marker is top-level to ingest,
so it does not count; neither does a `reply` marker whose event id is
malformed, nor a bare/unmarked `e`-tag (mentions, quotes).
- **Desktop** (`workflowFormTypes.ts`, `WorkflowStepCard.tsx`):
`replyInThread` form field with YAML round-trip, and a "Reply to
triggering message in thread" checkbox shown only for message-based
triggers. Switching to `schedule` or `webhook` clears the option on
every step before serialization, so a value hidden behind an action
change cannot resurrect when the action is switched back. The form
parser also requires `reply_in_thread` to be a boolean and refuses
`reply_in_thread: true` under an ineligible trigger, falling back to
YAML mode rather than silently normalizing a backend-invalid definition.

## Usage

```yaml
trigger:
  on: message_posted
  filter: trigger_is_reply == false
steps:
  - id: auto_reply
    action: send_message
    text: "pre-written reply text"
    reply_in_thread: true
```

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-20 19:44:21 -04:00

198 lines
7.2 KiB
Rust

//! Shared NIP-10 thread-marker parsing.
//!
//! One parser for the `root`/`reply` markers on an event's `e` tags, so every
//! consumer reads ancestry the same way. The relay ingest resolver
//! (`resolve_nip10_thread_meta`) and the workflow `trigger_is_reply` predicate
//! both call this — a second hand-rolled copy is exactly how the two drifted on
//! marker semantics and on id-validity.
//!
//! Validity mirrors ingest: a marker counts only when its event id is exactly
//! 64 ASCII-hex characters. A malformed id (e.g. `["e","bad","","reply"]`) is
//! ignored, never treated as a thread link.
/// The `root` and `reply` event ids parsed from an event's NIP-10 `e` tags.
///
/// Each is `Some(id_hex)` only when a marker of that kind carried a valid
/// 64-hex event id. The last valid occurrence of each marker wins, matching
/// the relay resolver's single-pass overwrite.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ThreadMarkers {
/// Event id from a valid `["e", <64-hex>, <relay>, "root"]` tag.
pub root: Option<String>,
/// Event id from a valid `["e", <64-hex>, <relay>, "reply"]` tag.
pub reply: Option<String>,
}
impl ThreadMarkers {
/// Collapse the `root`/`reply` markers into a reply's `(root_id, parent_id)`.
///
/// This is the single definition of the NIP-10 resolution rule shared by
/// consumers that classify a reply's own root/parent or recover a parent's
/// ancestry (relay ingest, ACP anchoring, and the CLI).
///
/// - `root` + `reply` → `(root, reply)` — a nested reply names both.
/// - `reply` only → `(reply, reply)` — a direct reply to the root; the
/// reply target is itself the thread root.
/// - `root` only or neither → `None` — no `reply` marker means the event is
/// top-level, matching ingest (a lone `root` tag never anchors a reply).
pub fn resolve(&self) -> Option<(String, String)> {
match (&self.root, &self.reply) {
(Some(root), Some(reply)) => Some((root.clone(), reply.clone())),
(None, Some(reply)) => Some((reply.clone(), reply.clone())),
(Some(_), None) | (None, None) => None,
}
}
}
/// Return true when `id` is exactly 64 ASCII-hex characters — the shape a
/// Nostr event id must have to be a real thread link.
fn is_event_id_hex(id: &str) -> bool {
id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit())
}
/// Parse the NIP-10 `root`/`reply` markers from an event's tags.
///
/// Only `e` tags with a marker (`parts.len() >= 4`) and a valid 64-hex event id
/// are considered; everything else is ignored.
pub fn parse_thread_markers(tags: &nostr::Tags) -> ThreadMarkers {
parse_thread_markers_from_parts(tags.iter().map(nostr::Tag::as_slice))
}
/// Same parser as [`parse_thread_markers`], for consumers that hold raw tag
/// arrays (e.g. decoded JSON `tags`) rather than a [`nostr::Tags`].
///
/// Each tag is a slice of string-like parts (`["e", <id>, <relay>, <marker>]`).
pub fn parse_thread_markers_from_parts<'a, S, I>(tags: I) -> ThreadMarkers
where
S: AsRef<str> + 'a,
I: IntoIterator<Item = &'a [S]>,
{
let mut markers = ThreadMarkers::default();
for parts in tags {
if parts.len() >= 4 && parts[0].as_ref() == "e" && is_event_id_hex(parts[1].as_ref()) {
match parts[3].as_ref() {
"root" => markers.root = Some(parts[1].as_ref().to_string()),
"reply" => markers.reply = Some(parts[1].as_ref().to_string()),
_ => {}
}
}
}
markers
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
fn markers_for(tags: Vec<Tag>) -> ThreadMarkers {
let event = EventBuilder::new(Kind::Custom(9), "")
.tags(tags)
.sign_with_keys(&Keys::generate())
.expect("sign");
parse_thread_markers(&event.tags)
}
fn id() -> String {
"a".repeat(64)
}
#[test]
fn no_e_tags_yields_no_markers() {
assert_eq!(markers_for(vec![]), ThreadMarkers::default());
}
#[test]
fn root_and_reply_both_parsed() {
let m = markers_for(vec![
Tag::parse(["e", &id(), "", "root"]).unwrap(),
Tag::parse(["e", &"b".repeat(64), "", "reply"]).unwrap(),
]);
assert_eq!(m.root.as_deref(), Some(id().as_str()));
assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str()));
}
#[test]
fn reply_only_marker_parsed() {
let m = markers_for(vec![Tag::parse(["e", &id(), "", "reply"]).unwrap()]);
assert_eq!(m.reply.as_deref(), Some(id().as_str()));
assert!(m.root.is_none());
}
#[test]
fn bare_e_tag_without_marker_is_ignored() {
let m = markers_for(vec![Tag::parse(["e", &id()]).unwrap()]);
assert_eq!(m, ThreadMarkers::default());
}
#[test]
fn malformed_id_is_ignored_for_both_markers() {
// Ingest gates the marker on a valid 64-hex id; a malformed id is not a
// thread link, so neither marker is set.
let m = markers_for(vec![
Tag::parse(["e", "bad", "", "reply"]).unwrap(),
Tag::parse(["e", "also-bad", "", "root"]).unwrap(),
]);
assert_eq!(m, ThreadMarkers::default());
}
#[test]
fn valid_root_with_malformed_reply_is_top_level() {
// A valid root but a malformed reply id: reply is ignored, so this is
// top-level to ingest (root-only) and must be so here too.
let m = markers_for(vec![
Tag::parse(["e", &id(), "", "root"]).unwrap(),
Tag::parse(["e", "bad", "", "reply"]).unwrap(),
]);
assert_eq!(m.root.as_deref(), Some(id().as_str()));
assert!(m.reply.is_none());
}
#[test]
fn resolve_root_and_reply_keeps_both() {
let m = ThreadMarkers {
root: Some("r".repeat(64)),
reply: Some("p".repeat(64)),
};
assert_eq!(m.resolve(), Some(("r".repeat(64), "p".repeat(64))));
}
#[test]
fn resolve_reply_only_is_direct_reply_to_root() {
let m = ThreadMarkers {
root: None,
reply: Some(id()),
};
assert_eq!(m.resolve(), Some((id(), id())));
}
#[test]
fn resolve_root_only_is_top_level() {
let m = ThreadMarkers {
root: Some(id()),
reply: None,
};
assert_eq!(m.resolve(), None);
}
#[test]
fn resolve_no_markers_is_top_level() {
assert_eq!(ThreadMarkers::default().resolve(), None);
}
#[test]
fn parse_from_parts_matches_tags_path() {
// The slice-based entry point must gate id validity and select markers
// identically to the `nostr::Tags` path.
let tags: Vec<Vec<String>> = vec![
vec!["e".into(), id(), "".into(), "root".into()],
vec!["e".into(), "b".repeat(64), "".into(), "reply".into()],
vec!["e".into(), "bad".into(), "".into(), "reply".into()],
vec!["p".into(), "abc".into()],
];
let m = parse_thread_markers_from_parts(tags.iter().map(Vec::as_slice));
assert_eq!(m.root.as_deref(), Some(id().as_str()));
assert_eq!(m.reply.as_deref(), Some("b".repeat(64).as_str()));
}
}