mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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>
198 lines
7.2 KiB
Rust
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()));
|
|
}
|
|
}
|