mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
170 lines
6.7 KiB
Rust
170 lines
6.7 KiB
Rust
//! Relay-side implementation of [`ActionSink`] for workflow actions.
|
|
//!
|
|
//! Builds Nostr events, persists them, and delegates post-persist side effects
|
|
//! (WebSocket fan-out, Redis pub/sub, search indexing, audit logging) to the
|
|
//! existing [`dispatch_persistent_event`] helper.
|
|
|
|
use std::future::Future;
|
|
use std::pin::Pin;
|
|
use std::sync::{Arc, Weak};
|
|
|
|
use buzz_core::kind::KIND_STREAM_MESSAGE;
|
|
use buzz_workflow::action_sink::{ActionSink, ActionSinkError};
|
|
use chrono::Utc;
|
|
use nostr::{EventBuilder, Kind, Tag};
|
|
use tracing::info;
|
|
use uuid::Uuid;
|
|
|
|
use crate::handlers::event::dispatch_persistent_event;
|
|
use crate::state::AppState;
|
|
|
|
/// Relay-side action sink — executes workflow side-effects directly.
|
|
///
|
|
/// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle:
|
|
/// `AppState` → `WorkflowEngine` → `ActionSink` → `AppState`. Using `Weak`
|
|
/// breaks the cycle so all structs can be dropped on shutdown.
|
|
///
|
|
/// Post-persist side effects are delegated to [`dispatch_persistent_event`]
|
|
/// for consistency with the REST/WebSocket paths.
|
|
pub struct RelayActionSink {
|
|
state: Weak<AppState>,
|
|
}
|
|
|
|
impl RelayActionSink {
|
|
/// Create a new `RelayActionSink` from the shared application state.
|
|
pub fn new(state: &Arc<AppState>) -> Self {
|
|
Self {
|
|
state: Arc::downgrade(state),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ActionSink for RelayActionSink {
|
|
fn send_message(
|
|
&self,
|
|
channel_id: &str,
|
|
text: &str,
|
|
author_pubkey: &str,
|
|
) -> Pin<Box<dyn Future<Output = Result<String, ActionSinkError>> + Send + '_>> {
|
|
let channel_id = channel_id.to_owned();
|
|
let text = text.to_owned();
|
|
let author_pubkey = author_pubkey.to_owned();
|
|
|
|
Box::pin(async move {
|
|
// 0. Upgrade weak reference — fails only during shutdown.
|
|
let state = self
|
|
.state
|
|
.upgrade()
|
|
.ok_or_else(|| ActionSinkError::Database("relay is shutting down".into()))?;
|
|
|
|
// 1. Validate content is not empty/whitespace-only
|
|
if text.trim().is_empty() {
|
|
return Err(ActionSinkError::EmptyContent);
|
|
}
|
|
|
|
// 2. Parse and validate channel — canonicalize UUID immediately
|
|
let channel_uuid = Uuid::parse_str(&channel_id)
|
|
.map_err(|e| ActionSinkError::InvalidInput(format!("invalid UUID: {e}")))?;
|
|
let channel_id_canonical = channel_uuid.to_string();
|
|
|
|
let channel = state
|
|
.db
|
|
.get_channel(channel_uuid)
|
|
.await
|
|
.map_err(|e| match &e {
|
|
buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::NotFound(_) => {
|
|
ActionSinkError::ChannelNotFound(channel_id_canonical.clone())
|
|
}
|
|
_ => ActionSinkError::Database(e.to_string()),
|
|
})?;
|
|
|
|
if channel.archived_at.is_some() {
|
|
return Err(ActionSinkError::ChannelArchived(
|
|
channel_id_canonical.clone(),
|
|
));
|
|
}
|
|
|
|
let author_pubkey = nostr::PublicKey::from_hex(&author_pubkey).map_err(|e| {
|
|
ActionSinkError::InvalidInput(format!("invalid author pubkey: {e}"))
|
|
})?;
|
|
let author_pubkey_bytes = author_pubkey.to_bytes().to_vec();
|
|
let author_pubkey_hex = author_pubkey.to_hex();
|
|
let is_member = state
|
|
.is_member_cached(channel_uuid, &author_pubkey_bytes)
|
|
.await
|
|
.map_err(|e| ActionSinkError::Database(e.to_string()))?;
|
|
if !is_member && channel.visibility != "open" {
|
|
return Err(ActionSinkError::InvalidInput(
|
|
"workflow owner does not have access to destination channel".into(),
|
|
));
|
|
}
|
|
|
|
// 3. Build kind:9 Nostr event
|
|
// - Signed by relay keypair (event.pubkey = relay pubkey)
|
|
// - `p` tag attributes the message to the workflow owner
|
|
// - `h` tag scopes to the channel (NIP-29, canonical UUID)
|
|
// - `buzz:workflow` tag prevents recursive workflow triggering
|
|
let tags = vec![
|
|
Tag::parse(["p", &author_pubkey_hex])
|
|
.map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?,
|
|
Tag::parse(["h", &channel_id_canonical])
|
|
.map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?,
|
|
Tag::parse(["buzz:workflow", "true"])
|
|
.map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?,
|
|
];
|
|
|
|
let kind = Kind::from(KIND_STREAM_MESSAGE as u16);
|
|
let event = EventBuilder::new(kind, &text)
|
|
.tags(tags)
|
|
.sign_with_keys(&state.relay_keypair)
|
|
.map_err(|e| ActionSinkError::EventBuild(format!("signing: {e}")))?;
|
|
|
|
let event_id_hex = event.id.to_hex();
|
|
let event_id_bytes = event.id.as_bytes().to_vec();
|
|
let kind_u32 = KIND_STREAM_MESSAGE;
|
|
|
|
let event_created_at = {
|
|
let ts = event.created_at.as_secs() as i64;
|
|
chrono::DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
|
|
};
|
|
|
|
info!(
|
|
event_id = %event_id_hex,
|
|
channel_id = %channel_id_canonical,
|
|
author = %author_pubkey,
|
|
"Workflow SendMessage: posting kind {kind_u32} event"
|
|
);
|
|
|
|
// 4. Persist event with thread metadata (matches REST handler path).
|
|
// Workflow messages are always top-level: depth=0, no parent/root.
|
|
let thread_meta = Some(buzz_db::event::ThreadMetadataParams {
|
|
event_id: &event_id_bytes,
|
|
event_created_at,
|
|
channel_id: channel_uuid,
|
|
parent_event_id: None,
|
|
parent_event_created_at: None,
|
|
root_event_id: None,
|
|
root_event_created_at: None,
|
|
depth: 0,
|
|
broadcast: false,
|
|
});
|
|
|
|
let (stored_event, was_inserted) = state
|
|
.db
|
|
.insert_event_with_thread_metadata(&event, Some(channel_uuid), thread_meta)
|
|
.await
|
|
.map_err(|e| ActionSinkError::Database(e.to_string()))?;
|
|
|
|
// 5. Post-persist side effects (fan-out, search, audit)
|
|
// Only if actually inserted (idempotency guard).
|
|
if was_inserted {
|
|
let _ =
|
|
dispatch_persistent_event(&state, &stored_event, kind_u32, &author_pubkey_hex)
|
|
.await;
|
|
}
|
|
|
|
Ok(event_id_hex)
|
|
})
|
|
}
|
|
}
|