mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +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>
74 lines
2.9 KiB
Rust
74 lines
2.9 KiB
Rust
//! Action sink trait — interface for workflow side-effects.
|
|
//!
|
|
//! The relay implements [`ActionSink`] to provide direct DB access to the
|
|
//! executor, replacing the HTTP loopback pattern.
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
|
|
use buzz_core::tenant::CommunityId;
|
|
|
|
/// Errors from action sink operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ActionSinkError {
|
|
/// An input parameter is malformed (e.g. invalid UUID).
|
|
#[error("invalid input: {0}")]
|
|
InvalidInput(String),
|
|
/// The target channel does not exist.
|
|
#[error("channel not found: {0}")]
|
|
ChannelNotFound(String),
|
|
/// The target channel is archived.
|
|
#[error("channel is archived: {0}")]
|
|
ChannelArchived(String),
|
|
/// Nostr event construction or signing failed.
|
|
#[error("event construction failed: {0}")]
|
|
EventBuild(String),
|
|
/// A database operation failed.
|
|
#[error("database error: {0}")]
|
|
Database(String),
|
|
/// Message content is empty or whitespace-only.
|
|
#[error("empty message content")]
|
|
EmptyContent,
|
|
}
|
|
|
|
impl From<ActionSinkError> for crate::WorkflowError {
|
|
fn from(e: ActionSinkError) -> Self {
|
|
crate::WorkflowError::WebhookError(e.to_string())
|
|
}
|
|
}
|
|
|
|
/// Interface for workflow actions that produce side effects.
|
|
///
|
|
/// Implemented by the relay to provide direct DB/event access to the executor.
|
|
/// This replaces the HTTP loopback where the executor POSTed to the relay's
|
|
/// REST API (which failed with 401 auth errors).
|
|
///
|
|
/// Returns `Pin<Box<dyn Future>>` for dyn-compatibility — required because
|
|
/// `WorkflowEngine` stores `Arc<dyn ActionSink>`.
|
|
pub trait ActionSink: Send + Sync {
|
|
/// Post a message to a channel on behalf of a workflow owner.
|
|
///
|
|
/// - `community_id`: the server-resolved community that owns the workflow
|
|
/// run driving this side effect. The relay-signed message is published
|
|
/// under *this* community, never the deployment/default tenant — the run
|
|
/// carries its owning community so a workflow in community B posts into B
|
|
/// even though the side effect has no inbound connection to bind.
|
|
/// - `channel_id`: UUID string of the target channel
|
|
/// - `text`: message body (must not be empty/whitespace-only)
|
|
/// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for
|
|
/// the `p` attribution tag; the relay keypair signs the event)
|
|
/// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a
|
|
/// threaded reply to that event (NIP-10 root/reply tags + real thread
|
|
/// metadata); when `None`, it is a top-level channel message.
|
|
///
|
|
/// Returns the event ID hex string on success.
|
|
fn send_message(
|
|
&self,
|
|
community_id: CommunityId,
|
|
channel_id: &str,
|
|
text: &str,
|
|
author_pubkey: &str,
|
|
reply_to: Option<&str>,
|
|
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>>;
|
|
}
|