mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
**Category:** improvement **User Impact:** Users can scan what each workflow does and trigger, edit, duplicate, enable, disable, or delete it directly from the library. **Problem:** The workflow list buried common actions and did not expose each automation's trigger-to-action shape at a glance. **Solution:** Add a responsive workflow library with a persistent create tile, compact trigger/action diagrams, prominent workflow titles with supporting descriptions, and shared card actions while preserving existing detail, editor, and run-history entry points. Card toggles refresh both list and open-detail caches so status and definition stay consistent. <details> <summary>File changes</summary> **desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx** Adds a shared card menu for trigger, edit, duplicate, enable/disable, and delete actions. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Reworks cards around the prototype's visual hierarchy: color-coded trigger, action flow, sentence-case eyebrow, prominent title, supporting description, status, channel, and update date without a footer clock icon. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Adds the responsive grid, create tile, mutation wiring, and list/detail cache invalidation. Container breakpoints keep cards two-across at medium widths and three-across in the 1280px desktop layout. **desktop/src/features/workflows/ui/workflowDefinition.ts** Adds immutable enabled-state updates plus narrow trigger and first-action readers used only to select card icons. **desktop/src/features/workflows/ui/workflowDefinition.test.mjs** Covers neutral icon selection, enabled-state immutability, and status presentation. **desktop/tests/e2e/workflows.spec.ts** Covers the create tile, title/description hierarchy, selected-card enable/disable consistency, and deterministic narrow/medium/wide captures while retaining existing action coverage. </details> ## Reproduction steps 1. Open **Workflows** and confirm the create tile stays first as cards flow from one to three columns with available width. 2. Confirm each card shows a sentence-case trigger eyebrow, prominent workflow title, supporting description when present, status, channel, and update date without a clock icon. 3. Open a card's overflow menu and trigger, edit, duplicate, enable/disable, or delete the workflow. 4. Leave the detail panel open while toggling and confirm its badge and JSON definition update with the card. ## Screenshots Real built E2E UI with representative workflow data at three viewport sizes. ### Narrow — 800 × 720  ### Medium — 1024 × 720  ### Wide — 1280 × 720  ### Card actions  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
965 lines
35 KiB
Rust
965 lines
35 KiB
Rust
//! Signed-event builders for desktop write operations.
|
||
//!
|
||
//! Mirrors the buzz-sdk builder patterns but uses nostr 0.37 API
|
||
//! (the desktop is excluded from the workspace which pins nostr 0.36).
|
||
//!
|
||
//! Mental model:
|
||
//! caller params → build_*() → EventBuilder → submit_event() signs + POSTs
|
||
//!
|
||
//! Each function validates inputs and returns a nostr::EventBuilder.
|
||
//! Signing and submission happen in relay::submit_event.
|
||
use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
|
||
use nostr::{EventBuilder, EventId, Kind, Tag};
|
||
use uuid::Uuid;
|
||
|
||
mod message_tags;
|
||
|
||
use message_tags::{
|
||
append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags,
|
||
};
|
||
// ── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
/// Maximum content size — matches buzz-sdk (64 KiB).
|
||
const MAX_CONTENT_BYTES: usize = 64 * 1024;
|
||
|
||
/// Maximum mention count — matches buzz-sdk.
|
||
const MAX_MENTIONS: usize = 50;
|
||
|
||
/// Maximum emoji length in characters — matches buzz-sdk.
|
||
const MAX_EMOJI_CHARS: usize = 64;
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
fn tag(parts: Vec<&str>) -> Result<Tag, String> {
|
||
Tag::parse(parts).map_err(|e| format!("invalid tag: {e}"))
|
||
}
|
||
|
||
fn check_content(content: &str) -> Result<(), String> {
|
||
if content.len() > MAX_CONTENT_BYTES {
|
||
return Err(format!(
|
||
"content exceeds maximum size of {} bytes (got {})",
|
||
MAX_CONTENT_BYTES,
|
||
content.len()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// NIP-10 thread reference.
|
||
pub struct ThreadRef {
|
||
pub root_event_id: EventId,
|
||
pub parent_event_id: EventId,
|
||
}
|
||
|
||
fn thread_tags(tr: &ThreadRef) -> Result<Vec<Tag>, String> {
|
||
let root = tr.root_event_id.to_hex();
|
||
let parent = tr.parent_event_id.to_hex();
|
||
if root == parent {
|
||
Ok(vec![tag(vec!["e", &root, "", "reply"])?])
|
||
} else {
|
||
Ok(vec![
|
||
tag(vec!["e", &root, "", "root"])?,
|
||
tag(vec!["e", &parent, "", "reply"])?,
|
||
])
|
||
}
|
||
}
|
||
|
||
fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {
|
||
if mentions.len() > MAX_MENTIONS {
|
||
return Err(format!("too many mentions (max {MAX_MENTIONS})"));
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut tags = Vec::new();
|
||
for &hex in mentions {
|
||
check_pubkey(hex)?;
|
||
let lower = hex.to_ascii_lowercase();
|
||
if seen.insert(lower.clone()) {
|
||
tags.push(tag(vec!["p", &lower])?);
|
||
}
|
||
}
|
||
Ok(tags)
|
||
}
|
||
|
||
/// Validate a hex pubkey is exactly 64 hex characters.
|
||
fn check_pubkey(pubkey: &str) -> Result<(), String> {
|
||
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(format!(
|
||
"pubkey must be a 64-character hex string (got {} chars)",
|
||
pubkey.len()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ── Channel operations ───────────────────────────────────────────────────────
|
||
|
||
/// Kind 9007 — create channel.
|
||
pub fn build_create_channel(
|
||
channel_id: Uuid,
|
||
name: &str,
|
||
visibility: &str,
|
||
channel_type: &str,
|
||
about: Option<&str>,
|
||
ttl_seconds: Option<i32>,
|
||
) -> Result<EventBuilder, String> {
|
||
let name = buzz_sdk_pkg::canonical_channel_name(name);
|
||
if name.trim().is_empty() {
|
||
return Err("channel name is required".into());
|
||
}
|
||
let mut tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["name", name])?,
|
||
tag(vec!["visibility", visibility])?,
|
||
tag(vec!["channel_type", channel_type])?,
|
||
];
|
||
if let Some(a) = about {
|
||
tags.push(tag(vec!["about", a])?);
|
||
}
|
||
if let Some(ttl) = ttl_seconds {
|
||
tags.push(tag(vec!["ttl", &ttl.to_string()])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9021 — join channel.
|
||
pub fn build_join(channel_id: Uuid) -> Result<EventBuilder, String> {
|
||
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9021), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9022 — leave channel.
|
||
pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, String> {
|
||
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9002 — update channel name/description/visibility/ttl.
|
||
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
|
||
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
|
||
pub fn build_update_channel(
|
||
channel_id: Uuid,
|
||
name: Option<&str>,
|
||
about: Option<&str>,
|
||
visibility: Option<&str>,
|
||
ttl: Option<Option<i32>>,
|
||
) -> Result<EventBuilder, String> {
|
||
if name.is_none() && about.is_none() && visibility.is_none() && ttl.is_none() {
|
||
return Err("at least one of name, about, visibility, or ttl must be provided".into());
|
||
}
|
||
if let Some(v) = visibility {
|
||
if v != "open" && v != "private" {
|
||
return Err("visibility must be \"open\" or \"private\"".into());
|
||
}
|
||
}
|
||
let name = name.map(buzz_sdk_pkg::canonical_channel_name);
|
||
if name.is_some_and(|name| name.trim().is_empty()) {
|
||
return Err("channel name is required".into());
|
||
}
|
||
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
if let Some(n) = name {
|
||
tags.push(tag(vec!["name", n])?);
|
||
}
|
||
if let Some(a) = about {
|
||
tags.push(tag(vec!["about", a])?);
|
||
}
|
||
if let Some(v) = visibility {
|
||
tags.push(tag(vec!["visibility", v])?);
|
||
}
|
||
if let Some(ttl) = ttl {
|
||
match ttl {
|
||
Some(secs) => tags.push(tag(vec!["ttl", &secs.to_string()])?),
|
||
None => tags.push(tag(vec!["ttl", ""])?),
|
||
}
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9002 — set topic.
|
||
pub fn build_set_topic(channel_id: Uuid, topic: &str) -> Result<EventBuilder, String> {
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["topic", topic])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9002 — set purpose.
|
||
pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder, String> {
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["purpose", purpose])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9002 — archive.
|
||
pub fn build_archive(channel_id: Uuid) -> Result<EventBuilder, String> {
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["archived", "true"])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9002 — unarchive.
|
||
pub fn build_unarchive(channel_id: Uuid) -> Result<EventBuilder, String> {
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["archived", "false"])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9008 — delete channel.
|
||
pub fn build_delete_channel(channel_id: Uuid) -> Result<EventBuilder, String> {
|
||
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9008), "").tags(tags))
|
||
}
|
||
|
||
// ── Membership ───────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 9000 — add member.
|
||
pub fn build_add_member(
|
||
channel_id: Uuid,
|
||
target_pubkey: &str,
|
||
role: Option<&str>,
|
||
) -> Result<EventBuilder, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
let mut tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
];
|
||
if let Some(r) = role {
|
||
tags.push(tag(vec!["role", r])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(9000), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9001 — remove member.
|
||
pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result<EventBuilder, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags))
|
||
}
|
||
|
||
// ── Messages ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 9 — stream message.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn build_message(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: Option<&ThreadRef>,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
custom_emoji_tags: &[Vec<String>],
|
||
mention_ref_tags: &[Vec<String>],
|
||
link_preview_tags: &[Vec<String>],
|
||
sent_from_thread_tag: Option<&[String]>,
|
||
relay_base: &str,
|
||
) -> Result<EventBuilder, String> {
|
||
build_message_with_client_tags(
|
||
channel_id,
|
||
content,
|
||
thread_ref,
|
||
mentions,
|
||
media_tags,
|
||
custom_emoji_tags,
|
||
mention_ref_tags,
|
||
link_preview_tags,
|
||
sent_from_thread_tag,
|
||
relay_base,
|
||
&[],
|
||
)
|
||
}
|
||
|
||
/// Kind 9 — stream message with internal client marker tags.
|
||
///
|
||
/// This is intentionally narrower than arbitrary extra tags: callers can add
|
||
/// only `["client", ...]` tags, which are useful for idempotency markers but
|
||
/// cannot forge channel/thread/mention metadata.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn build_message_with_client_tags(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: Option<&ThreadRef>,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
custom_emoji_tags: &[Vec<String>],
|
||
mention_ref_tags: &[Vec<String>],
|
||
link_preview_tags: &[Vec<String>],
|
||
sent_from_thread_tag: Option<&[String]>,
|
||
relay_base: &str,
|
||
client_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, String> {
|
||
if sent_from_thread_tag.is_some() && thread_ref.is_some() {
|
||
return Err("sent-from-thread provenance requires a top-level message".into());
|
||
}
|
||
check_content(content)?;
|
||
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
if let Some(tr) = thread_ref {
|
||
tags.extend(thread_tags(tr)?);
|
||
}
|
||
tags.extend(mention_tags(mentions)?);
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
emoji_tags(custom_emoji_tags, &mut tags)?;
|
||
mention_reference_tags(mention_ref_tags, &mut tags)?;
|
||
crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?;
|
||
append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?;
|
||
append_client_tags(client_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
|
||
}
|
||
|
||
/// Kind 45001 — forum post.
|
||
pub fn build_forum_post(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
mention_ref_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
tags.extend(mention_tags(mentions)?);
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
mention_reference_tags(mention_ref_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(45001), content).tags(tags))
|
||
}
|
||
|
||
/// Kind 45003 — forum comment.
|
||
pub fn build_forum_comment(
|
||
channel_id: Uuid,
|
||
content: &str,
|
||
thread_ref: &ThreadRef,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
mention_ref_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
tags.extend(thread_tags(thread_ref)?);
|
||
tags.extend(mention_tags(mentions)?);
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
mention_reference_tags(mention_ref_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags))
|
||
}
|
||
|
||
pub struct MessageEditTags<'a> {
|
||
pub media: &'a [Vec<String>],
|
||
pub custom_emoji: &'a [Vec<String>],
|
||
pub mentions: &'a [&'a str],
|
||
pub mention_refs: Option<&'a [Vec<String>]>,
|
||
}
|
||
|
||
/// Kind 40003 — edit a message with full content, media, emoji, mentions,
|
||
/// and optional monotonic link-preview suppression.
|
||
pub fn build_message_edit(
|
||
channel_id: Uuid,
|
||
target_event_id: EventId,
|
||
content: &str,
|
||
edit_tags: MessageEditTags<'_>,
|
||
suppress_link_previews: bool,
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let mut tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["e", &target_event_id.to_hex()])?,
|
||
];
|
||
tags.extend(mention_tags(edit_tags.mentions)?);
|
||
imeta_tags(edit_tags.media, &mut tags)?;
|
||
emoji_tags(edit_tags.custom_emoji, &mut tags)?;
|
||
if let Some(mention_refs) = edit_tags.mention_refs {
|
||
mention_reference_tags(mention_refs, &mut tags)?;
|
||
tags.push(tag(vec!["buzz:mention-snapshot"])?);
|
||
}
|
||
if suppress_link_previews {
|
||
tags.push(tag(vec!["link-preview", "none"])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
|
||
}
|
||
|
||
/// Kind 5 — NIP-09 deletion. The `h` tag is non-standard for NIP-09 but is
|
||
/// required so channel-scoped subscriptions observe the delete.
|
||
pub fn build_delete_compat(
|
||
channel_id: Uuid,
|
||
target_event_id: EventId,
|
||
) -> Result<EventBuilder, String> {
|
||
let tags = vec![
|
||
tag(vec!["h", &channel_id.to_string()])?,
|
||
tag(vec!["e", &target_event_id.to_hex()])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||
}
|
||
|
||
// ── Reactions ────────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 7 — NIP-25 reaction.
|
||
pub fn build_reaction(target_event_id: EventId, emoji: &str) -> Result<EventBuilder, String> {
|
||
if emoji.chars().count() > MAX_EMOJI_CHARS {
|
||
return Err(format!(
|
||
"emoji exceeds maximum length of {MAX_EMOJI_CHARS} characters"
|
||
));
|
||
}
|
||
let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(7), emoji).tags(tags))
|
||
}
|
||
|
||
/// Kind 5 — delete a reaction event.
|
||
pub fn build_remove_reaction(reaction_event_id: EventId) -> Result<EventBuilder, String> {
|
||
let tags = vec![tag(vec!["e", &reaction_event_id.to_hex()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags))
|
||
}
|
||
|
||
// ── Canvas ───────────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 40100 — set canvas.
|
||
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
|
||
}
|
||
|
||
// ── Profile ──────────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 0 — NIP-01 profile metadata (full snapshot).
|
||
pub fn build_profile(
|
||
display_name: Option<&str>,
|
||
name: Option<&str>,
|
||
picture: Option<&str>,
|
||
about: Option<&str>,
|
||
nip05: Option<&str>,
|
||
) -> Result<EventBuilder, String> {
|
||
let mut map = serde_json::Map::new();
|
||
if let Some(v) = display_name {
|
||
map.insert("display_name".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = name {
|
||
map.insert("name".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = picture {
|
||
map.insert("picture".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = about {
|
||
map.insert("about".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
if let Some(v) = nip05 {
|
||
map.insert("nip05".into(), serde_json::Value::String(v.into()));
|
||
}
|
||
let content = serde_json::Value::Object(map).to_string();
|
||
Ok(EventBuilder::new(Kind::Custom(0), content))
|
||
}
|
||
|
||
// ── Huddles ──────────────────────────────────────────────────────────────────
|
||
|
||
/// Validate that a string is a valid UUID (defense-in-depth for `&str` channel IDs).
|
||
fn validate_channel_id(id: &str) -> Result<(), String> {
|
||
uuid::Uuid::parse_str(id).map_err(|_| format!("invalid channel UUID: {id}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Shared builder for huddle lifecycle events (kinds 48100–48103).
|
||
/// All huddle events share: validate two channel IDs, JSON content with
|
||
/// `ephemeral_channel_id`, an `["h", parent_channel_id]` tag, and an
|
||
/// optional `["p", participant_pubkey]` tag for join/leave identity.
|
||
fn build_huddle_event(
|
||
kind: u16,
|
||
parent_channel_id: &str,
|
||
ephemeral_channel_id: &str,
|
||
extra_fields: &[(&str, &str)],
|
||
participant_pubkey: Option<&str>,
|
||
) -> Result<EventBuilder, String> {
|
||
validate_channel_id(parent_channel_id)?;
|
||
validate_channel_id(ephemeral_channel_id)?;
|
||
let mut content = serde_json::json!({
|
||
"ephemeral_channel_id": ephemeral_channel_id,
|
||
});
|
||
for (k, v) in extra_fields {
|
||
content[*k] = serde_json::Value::String(v.to_string());
|
||
}
|
||
let mut tags = vec![tag(vec!["h", parent_channel_id])?];
|
||
if let Some(pk) = participant_pubkey {
|
||
tags.push(tag(vec!["p", pk])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(kind), content.to_string()).tags(tags))
|
||
}
|
||
|
||
/// Kind 48100 — huddle started advisory posted to the parent channel.
|
||
pub fn build_huddle_started(
|
||
parent_channel_id: &str,
|
||
ephemeral_channel_id: &str,
|
||
) -> Result<EventBuilder, String> {
|
||
build_huddle_event(48100, parent_channel_id, ephemeral_channel_id, &[], None)
|
||
}
|
||
|
||
/// Kind 48103 — huddle ended, posted to the parent channel.
|
||
pub fn build_huddle_ended(
|
||
parent_channel_id: &str,
|
||
ephemeral_channel_id: &str,
|
||
) -> Result<EventBuilder, String> {
|
||
build_huddle_event(48103, parent_channel_id, ephemeral_channel_id, &[], None)
|
||
}
|
||
|
||
/// Kind 48106 — voice-mode guidelines for agents in a huddle.
|
||
///
|
||
/// Posted to the **ephemeral** channel (not the parent) so agents see it
|
||
/// via EOSE replay when they subscribe. Uses a dedicated kind so the TTS
|
||
/// pipeline can filter it out without fragile content-prefix matching.
|
||
pub fn build_huddle_guidelines(
|
||
ephemeral_channel_id: &str,
|
||
guidelines_text: &str,
|
||
) -> Result<EventBuilder, String> {
|
||
validate_channel_id(ephemeral_channel_id)?;
|
||
check_content(guidelines_text)?;
|
||
let tags = vec![tag(vec!["h", ephemeral_channel_id])?];
|
||
Ok(EventBuilder::new(Kind::Custom(48106), guidelines_text).tags(tags))
|
||
}
|
||
|
||
// ── Social notes ────────────────────────────────────────────────────────────
|
||
|
||
/// Kind 1 — NIP-01 short text note (global, no channel scope).
|
||
pub fn build_note(
|
||
content: &str,
|
||
reply_to_event_id: Option<EventId>,
|
||
mentions: &[&str],
|
||
media_tags: &[Vec<String>],
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let mut tags = Vec::new();
|
||
if let Some(parent) = reply_to_event_id {
|
||
tags.push(tag(vec!["e", &parent.to_hex(), "", "reply"])?);
|
||
}
|
||
tags.extend(mention_tags(mentions)?);
|
||
imeta_tags(media_tags, &mut tags)?;
|
||
Ok(EventBuilder::new(Kind::TextNote, content).tags(tags))
|
||
}
|
||
|
||
// ── Relay admin (NIP-43) ────────────────────────────────────────────────────
|
||
|
||
/// Allowed relay member roles for NIP-43 admin commands.
|
||
const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"];
|
||
|
||
fn check_relay_role(role: &str) -> Result<(), String> {
|
||
if !VALID_RELAY_ROLES.contains(&role) {
|
||
return Err(format!(
|
||
"invalid relay role \"{role}\" (expected one of: {})",
|
||
VALID_RELAY_ROLES.join(", ")
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Kind 9030 — add a pubkey to the relay member list.
|
||
pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result<EventBuilder, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
check_relay_role(role)?;
|
||
let tags = vec![
|
||
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
tag(vec!["role", role])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9031 — remove a pubkey from the relay member list.
|
||
pub fn build_relay_admin_remove(target_pubkey: &str) -> Result<EventBuilder, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?];
|
||
Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 9032 — change the role of an existing relay member.
|
||
pub fn build_relay_admin_change_role(
|
||
target_pubkey: &str,
|
||
new_role: &str,
|
||
) -> Result<EventBuilder, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
check_relay_role(new_role)?;
|
||
let tags = vec![
|
||
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
|
||
tag(vec!["role", new_role])?,
|
||
];
|
||
Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags))
|
||
}
|
||
|
||
// ── NIP-IA identity archival ─────────────────────────────────────────────────
|
||
//
|
||
// kind:9035 archive request, kind:9036 unarchive request.
|
||
// Both protected by NIP-70 (`["-"]`), p-tag the target, and may carry
|
||
// optional `reason` (machine-readable code), `replaced-by` (9035 only),
|
||
// and a NIP-OA `auth` tag for owner-of-agent requests.
|
||
//
|
||
// See docs/nips/NIP-IA.md §Event Formats. The relay verifies; the desktop's
|
||
// job is to produce a well-formed, signed request — consent path is selected
|
||
// by the relay, not declared here.
|
||
|
||
fn check_reason(reason: &str) -> Result<(), String> {
|
||
// Reason codes are machine-readable strings; the spec doesn't cap length
|
||
// but we keep them short to discourage stuffing prose where `content` goes.
|
||
if reason.len() > 64 {
|
||
return Err(format!(
|
||
"reason code exceeds maximum length of 64 chars (got {})",
|
||
reason.len()
|
||
));
|
||
}
|
||
if reason.chars().any(|c| c.is_control()) {
|
||
return Err("reason code must not contain control characters".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn identity_archive_tags(
|
||
target_pubkey: &str,
|
||
reason: Option<&str>,
|
||
replaced_by: Option<&str>,
|
||
auth_tag: Option<&[String; 4]>,
|
||
) -> Result<Vec<Tag>, String> {
|
||
check_pubkey(target_pubkey)?;
|
||
let target_lower = target_pubkey.to_ascii_lowercase();
|
||
|
||
let mut tags = Vec::with_capacity(5);
|
||
// NIP-70: mark as protected administrative state.
|
||
tags.push(tag(vec!["-"])?);
|
||
tags.push(tag(vec!["p", &target_lower])?);
|
||
|
||
if let Some(r) = reason {
|
||
check_reason(r)?;
|
||
tags.push(tag(vec!["reason", r])?);
|
||
}
|
||
|
||
if let Some(rb) = replaced_by {
|
||
check_pubkey(rb)?;
|
||
let rb_lower = rb.to_ascii_lowercase();
|
||
if rb_lower == target_lower {
|
||
return Err("replaced-by must differ from the target".into());
|
||
}
|
||
tags.push(tag(vec!["replaced-by", &rb_lower])?);
|
||
}
|
||
|
||
if let Some(auth) = auth_tag {
|
||
// Structural check only — the relay performs full NIP-OA verification.
|
||
// We require the label, a 64-hex owner pubkey, and a 128-hex signature.
|
||
if auth[0] != "auth" {
|
||
return Err(format!(
|
||
"auth tag label must be \"auth\" (got \"{}\")",
|
||
auth[0]
|
||
));
|
||
}
|
||
check_pubkey(&auth[1])?;
|
||
if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err("auth tag signature must be 128-character hex".into());
|
||
}
|
||
tags.push(tag(vec!["auth", &auth[1], &auth[2], &auth[3]])?);
|
||
}
|
||
|
||
Ok(tags)
|
||
}
|
||
|
||
/// Kind 9035 — NIP-IA archive request.
|
||
///
|
||
/// `content` is an optional human-readable reason (clients MUST NOT parse
|
||
/// authorization semantics from it). `reason` is the machine-readable code
|
||
/// (`rotated`, `retired`, `bot-rebuilt`, `left-organization`, `spam`, ...).
|
||
/// `replaced_by` is the rotation pointer. `auth` is a NIP-OA owner-attestation
|
||
/// tag required only for the owner-of-agent consent path.
|
||
///
|
||
/// `.allow_self_tagging()` is required: NIP-IA's self path has `actor==target`,
|
||
/// which means the request's `["p", target]` matches the signer. nostr 0.44
|
||
/// strips matching `p` tags by default — we need the wire form intact.
|
||
pub fn build_archive_identity_request(
|
||
target_pubkey: &str,
|
||
content: &str,
|
||
reason: Option<&str>,
|
||
replaced_by: Option<&str>,
|
||
auth: Option<&[String; 4]>,
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?;
|
||
Ok(
|
||
EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content)
|
||
.tags(tags)
|
||
.allow_self_tagging(),
|
||
)
|
||
}
|
||
|
||
/// Kind 9036 — NIP-IA unarchive request.
|
||
///
|
||
/// Same shape as 9035 minus `replaced-by` (which has no defined meaning on
|
||
/// unarchive per spec). `auth` is used for owner-of-agent unarchive paths.
|
||
/// See `build_archive_identity_request` for the rationale on
|
||
/// `.allow_self_tagging()`.
|
||
pub fn build_unarchive_identity_request(
|
||
target_pubkey: &str,
|
||
content: &str,
|
||
reason: Option<&str>,
|
||
auth: Option<&[String; 4]>,
|
||
) -> Result<EventBuilder, String> {
|
||
check_content(content)?;
|
||
let tags = identity_archive_tags(target_pubkey, reason, None, auth)?;
|
||
Ok(
|
||
EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content)
|
||
.tags(tags)
|
||
.allow_self_tagging(),
|
||
)
|
||
}
|
||
|
||
/// Maximum contacts per contact list event.
|
||
const MAX_CONTACTS: usize = 10_000;
|
||
|
||
/// Kind 3 — NIP-02 contact list (replaceable, full snapshot).
|
||
pub fn build_contact_list(
|
||
contacts: &[(&str, Option<&str>, Option<&str>)],
|
||
) -> Result<EventBuilder, String> {
|
||
if contacts.len() > MAX_CONTACTS {
|
||
return Err(format!(
|
||
"too many contacts (max {MAX_CONTACTS}, got {})",
|
||
contacts.len()
|
||
));
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut tags = Vec::new();
|
||
for &(pubkey, relay_url, petname) in contacts {
|
||
check_pubkey(pubkey)?;
|
||
let lower = pubkey.to_ascii_lowercase();
|
||
if seen.insert(lower.clone()) {
|
||
tags.push(tag(vec![
|
||
"p",
|
||
&lower,
|
||
relay_url.unwrap_or(""),
|
||
petname.unwrap_or(""),
|
||
])?);
|
||
}
|
||
}
|
||
Ok(EventBuilder::new(Kind::ContactList, "").tags(tags))
|
||
}
|
||
|
||
/// Kind 41010 — open (or surface) a DM channel with the given participants.
|
||
///
|
||
/// Each pubkey is added as a `p` tag. The relay derives the canonical
|
||
/// channel id and replies via OK message with `response:{channel_id}`.
|
||
pub fn build_dm_open(pubkeys: &[String]) -> Result<EventBuilder, String> {
|
||
if pubkeys.is_empty() {
|
||
return Err("dm_open requires at least one pubkey".into());
|
||
}
|
||
let mut tags: Vec<Tag> = Vec::with_capacity(pubkeys.len());
|
||
for pk in pubkeys {
|
||
check_pubkey(pk)?;
|
||
tags.push(tag(vec!["p", &pk.to_ascii_lowercase()])?);
|
||
}
|
||
Ok(EventBuilder::new(Kind::Custom(41010), "").tags(tags))
|
||
}
|
||
|
||
/// Kind 41012 — hide a DM channel from the user's listing.
|
||
pub fn build_dm_hide(channel_id: &str) -> Result<EventBuilder, String> {
|
||
let tags = vec![tag(vec!["h", channel_id])?];
|
||
Ok(EventBuilder::new(Kind::Custom(41012), "").tags(tags))
|
||
}
|
||
|
||
mod workflows;
|
||
|
||
pub use workflows::{
|
||
build_approval_deny, build_approval_grant, build_workflow_definition, build_workflow_delete,
|
||
build_workflow_trigger,
|
||
};
|
||
|
||
// ── Transport ────────────────────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use nostr::Keys;
|
||
#[test]
|
||
fn channel_builders_reject_hash_only_names() {
|
||
let channel_id = Uuid::new_v4();
|
||
assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err());
|
||
assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err());
|
||
}
|
||
/// Builder layout regression for the NIP-IA owner-of-agent archive flow.
|
||
/// Compares against `docs/nips/NIP-IA.md` §Vector 1.
|
||
#[test]
|
||
fn archive_identity_request_matches_spec_vector_1_layout() {
|
||
const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||
const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||
const CONDITIONS: &str = "kind=1&created_at<1713957000";
|
||
const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
|
||
|
||
let auth: [String; 4] = [
|
||
"auth".into(),
|
||
OWNER_HEX.into(),
|
||
CONDITIONS.into(),
|
||
SIG.into(),
|
||
];
|
||
let builder = build_archive_identity_request(
|
||
TARGET_HEX,
|
||
"Archiving zombie agent after rebuild.",
|
||
Some("bot-rebuilt"),
|
||
None,
|
||
Some(&auth),
|
||
)
|
||
.expect("build_archive_identity_request");
|
||
|
||
let owner_secret = nostr::SecretKey::from_hex(
|
||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||
)
|
||
.unwrap();
|
||
let owner_keys = Keys::new(owner_secret);
|
||
let event = builder.sign_with_keys(&owner_keys).unwrap();
|
||
|
||
let tags: Vec<Vec<String>> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect();
|
||
|
||
assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16));
|
||
// Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...]
|
||
assert_eq!(tags[0], vec!["-"]);
|
||
assert_eq!(tags[1], vec!["p", TARGET_HEX]);
|
||
assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]);
|
||
assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]);
|
||
}
|
||
|
||
#[test]
|
||
fn archive_request_rejects_replaced_by_equal_target() {
|
||
const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||
let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None)
|
||
.unwrap_err();
|
||
assert!(err.contains("replaced-by"));
|
||
}
|
||
|
||
#[test]
|
||
fn unarchive_request_layout_self_path() {
|
||
const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||
let builder = build_unarchive_identity_request(
|
||
TARGET_HEX,
|
||
"I am active again.",
|
||
Some("returned"),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let target_secret = nostr::SecretKey::from_hex(
|
||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||
)
|
||
.unwrap();
|
||
let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap();
|
||
let tags: Vec<Vec<String>> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect();
|
||
assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16));
|
||
// Self-unarchive: the `p` tag MUST point at the signer. Verifies our
|
||
// `.allow_self_tagging()` call survives nostr 0.44's default scrub.
|
||
assert_eq!(tags[0], vec!["-"]);
|
||
assert_eq!(tags[1], vec!["p", TARGET_HEX]);
|
||
assert_eq!(tags[2], vec!["reason", "returned"]);
|
||
assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag");
|
||
assert_eq!(event.pubkey.to_hex(), TARGET_HEX);
|
||
}
|
||
|
||
const CH_ID: &str = "11111111-1111-4111-8111-111111111111";
|
||
const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||
const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||
|
||
fn edit_tags(mentions: &[&str]) -> Vec<Vec<String>> {
|
||
edit_tags_with_refs(mentions, Some(&[]))
|
||
}
|
||
|
||
fn edit_tags_with_refs(
|
||
mentions: &[&str],
|
||
mention_refs: Option<&[Vec<String>]>,
|
||
) -> Vec<Vec<String>> {
|
||
let channel = Uuid::parse_str(CH_ID).unwrap();
|
||
let target =
|
||
EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1")
|
||
.unwrap();
|
||
let builder = build_message_edit(
|
||
channel,
|
||
target,
|
||
"hi @alice",
|
||
MessageEditTags {
|
||
media: &[],
|
||
custom_emoji: &[],
|
||
mentions,
|
||
mention_refs,
|
||
},
|
||
false,
|
||
)
|
||
.unwrap();
|
||
let secret = nostr::SecretKey::from_hex(
|
||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||
)
|
||
.unwrap();
|
||
let event = builder.sign_with_keys(&Keys::new(secret)).unwrap();
|
||
event.tags.iter().map(|t| t.as_slice().to_vec()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn edit_with_added_mention_emits_p_tag() {
|
||
let tags = edit_tags(&[ALICE_HEX]);
|
||
assert_eq!(tags[0][0], "h");
|
||
assert_eq!(tags[1][0], "e");
|
||
assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn edit_with_no_added_mentions_emits_no_p_tag() {
|
||
// Typo-fix edit: mention set unchanged, so the composer passes `&[]`.
|
||
// The edit event must carry no `p` tag and re-wake nobody.
|
||
let tags = edit_tags(&[]);
|
||
assert!(
|
||
!tags
|
||
.iter()
|
||
.any(|t| t.first().map(String::as_str) == Some("p")),
|
||
"unchanged-mention edit must not emit any `p` tag, got {tags:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn edit_emits_full_mention_reference_snapshot() {
|
||
let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]]));
|
||
assert!(
|
||
tags.iter().any(|tag| tag == &["mention", ALICE_HEX]),
|
||
"stable mention reference must be present: {tags:?}"
|
||
);
|
||
assert!(
|
||
tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]),
|
||
"snapshot marker must be present: {tags:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_edit_mention_snapshot_is_explicit() {
|
||
let tags = edit_tags_with_refs(&[], Some(&[]));
|
||
assert!(
|
||
tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]),
|
||
"empty snapshot must still clear stale references: {tags:?}"
|
||
);
|
||
assert!(!tags
|
||
.iter()
|
||
.any(|tag| tag.first().map(String::as_str) == Some("mention")));
|
||
}
|
||
|
||
#[test]
|
||
fn partial_edit_omits_mention_snapshot() {
|
||
let tags = edit_tags_with_refs(&[], None);
|
||
assert!(!tags
|
||
.iter()
|
||
.any(|tag| tag.first().map(String::as_str) == Some("mention")));
|
||
assert!(!tags
|
||
.iter()
|
||
.any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot")));
|
||
}
|
||
|
||
#[test]
|
||
fn edit_mentions_are_deduped_and_lowercased() {
|
||
let alice_upper = ALICE_HEX.to_ascii_uppercase();
|
||
let tags = edit_tags(&[ALICE_HEX, &alice_upper, BOB_HEX]);
|
||
let p_tags: Vec<&Vec<String>> = tags
|
||
.iter()
|
||
.filter(|t| t.first().map(String::as_str) == Some("p"))
|
||
.collect();
|
||
// ALICE appears twice (mixed case) but collapses to one lowercase tag.
|
||
assert_eq!(
|
||
p_tags.len(),
|
||
2,
|
||
"duplicate mention must collapse, got {p_tags:?}"
|
||
);
|
||
assert_eq!(p_tags[0], &vec!["p".to_string(), ALICE_HEX.to_string()]);
|
||
assert_eq!(p_tags[1], &vec!["p".to_string(), BOB_HEX.to_string()]);
|
||
}
|
||
}
|