Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
pub const KIND_STREAM_REMINDER: u32 = 40007;
/// A diff/patch message showing file changes (unified diff format).
pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008;
/// A response to an interactive card message (e.g. a to-do item check-off).
/// References the card via an `e` tag and the item via an `item` tag; card
/// state is a client-side fold over these events, latest per (item, pubkey).
pub const KIND_CARD_RESPONSE: u32 = 40009;
/// Canvas (shared document) for a channel.
pub const KIND_CANVAS: u32 = 40100;
/// System message for channel state changes (join, leave, rename, etc.).
Expand Down Expand Up @@ -707,6 +711,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_REMINDER,
KIND_STREAM_MESSAGE_DIFF,
KIND_CARD_RESPONSE,
KIND_CANVAS,
KIND_SYSTEM_MESSAGE,
KIND_CHANNEL_SUMMARY,
Expand Down
6 changes: 4 additions & 2 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,8 +1161,9 @@ mod tests {
use std::sync::Arc;

use buzz_core::kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST,
KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_CARD_RESPONSE, KIND_FORUM_COMMENT,
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
};
use buzz_core::observer::{
encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
Expand Down Expand Up @@ -1214,6 +1215,7 @@ mod tests {
for kind in [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_CARD_RESPONSE,
KIND_CANVAS,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
Expand Down
62 changes: 42 additions & 20 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,27 @@ use buzz_core::kind::{
event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET,
KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT,
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT,
KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
KIND_CANVAS, KIND_CARD_RESPONSE, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER,
KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER,
KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED,
KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN,
KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN,
KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST,
KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION,
KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM,
KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
Expand Down Expand Up @@ -253,6 +254,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
| KIND_STREAM_MESSAGE_SCHEDULED
| KIND_STREAM_REMINDER
| KIND_STREAM_MESSAGE_DIFF
| KIND_CARD_RESPONSE
| KIND_FORUM_POST
| KIND_FORUM_VOTE
| KIND_FORUM_COMMENT => Ok(Scope::MessagesWrite),
Expand Down Expand Up @@ -485,6 +487,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool {
| KIND_STREAM_MESSAGE_SCHEDULED
| KIND_STREAM_REMINDER
| KIND_STREAM_MESSAGE_DIFF
| KIND_CARD_RESPONSE
| KIND_CANVAS
| KIND_FORUM_POST
| KIND_FORUM_VOTE
Expand Down Expand Up @@ -3038,6 +3041,7 @@ mod tests {
for kind in [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_CARD_RESPONSE,
KIND_CANVAS,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
Expand All @@ -3050,6 +3054,23 @@ mod tests {
}
}

#[test]
fn card_response_requires_messages_write_scope() {
let dummy = make_dummy_event();
assert_eq!(
required_scope_for_kind(KIND_CARD_RESPONSE, &dummy).unwrap(),
Scope::MessagesWrite,
);
}

#[test]
fn card_response_is_channel_scoped_not_global() {
// kind:40009 rides the generic channel pipeline: missing h → rejected,
// membership enforced per h tag, never nulled to global.
assert!(requires_h_channel_scope(KIND_CARD_RESPONSE));
assert!(!is_global_only_kind(KIND_CARD_RESPONSE));
}

#[test]
fn nip29_admin_kinds_require_h_tags() {
for kind in [
Expand Down Expand Up @@ -3258,6 +3279,7 @@ mod tests {
KIND_NIP29_LEAVE_REQUEST,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_DIFF,
KIND_CARD_RESPONSE,
KIND_CANVAS,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
Expand Down
190 changes: 185 additions & 5 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//! Typed event builder functions (38 builders).
//! Typed event builder functions (40 builders).
//!
//! All functions return `Result<nostr::EventBuilder, SdkError>`.
//! The caller signs: `builder.sign_with_keys(&keys)?`.

use buzz_core::{
kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION,
KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_CARD_RESPONSE,
KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE,
KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST,
KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT,
Expand All @@ -23,7 +23,8 @@ use nostr::{EventBuilder, Kind, Tag};
use uuid::Uuid;

use crate::{
ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection,
ChannelKind, CustomEmoji, DiffMeta, MemberRole, SdkError, ThreadRef, TodoCardItem, Visibility,
VoteDirection,
};

/// Parse a tag slice, mapping errors to `SdkError::InvalidTag`.
Expand Down Expand Up @@ -459,6 +460,96 @@ pub fn build_vote(
Ok(EventBuilder::new(Kind::Custom(45002), content).tags(tags))
}

/// Maximum items allowed in a `buzz:todo-card` payload (NIP-TC MVP cap).
pub const MAX_TODO_CARD_ITEMS: usize = 20;

/// Build a stream message (kind 9) carrying a `buzz:todo-card` v1 sentinel.
///
/// `prose` is the plaintext fallback rendered by clients without card
/// support; the fenced JSON payload is appended after it. Each assignee is
/// p-tagged so they get mentioned. Check-offs come back as kind:40009
/// responses referencing the signed message's event id (see NIP-TC).
pub fn build_todo_card_message(
channel_id: Uuid,
prose: &str,
title: Option<&str>,
items: &[TodoCardItem],
) -> Result<EventBuilder, SdkError> {
if items.is_empty() || items.len() > MAX_TODO_CARD_ITEMS {
return Err(SdkError::InvalidInput(format!(
"todo card needs 1..={MAX_TODO_CARD_ITEMS} items (got {})",
items.len()
)));
}
let mut seen_ids = std::collections::HashSet::new();
for item in items {
if item.id.is_empty() || !seen_ids.insert(item.id.as_str()) {
return Err(SdkError::InvalidInput(format!(
"todo card item ids must be non-empty and unique (got {:?})",
item.id
)));
}
}

let mut json_items = Vec::with_capacity(items.len());
let mut assignees: Vec<String> = Vec::new();
for item in items {
let mut obj = serde_json::Map::new();
obj.insert("id".into(), serde_json::json!(item.id));
obj.insert("text".into(), serde_json::json!(item.text));
if let Some(assignee) = &item.assignee {
let assignee = check_pubkey_hex(assignee, "assignee")?;
obj.insert("assignee".into(), serde_json::json!(assignee));
if !assignees.contains(&assignee) {
assignees.push(assignee);
}
}
json_items.push(serde_json::Value::Object(obj));
}
let mut payload = serde_json::Map::new();
payload.insert("v".into(), serde_json::json!(1));
if let Some(title) = title {
payload.insert("title".into(), serde_json::json!(title));
}
payload.insert("items".into(), serde_json::Value::Array(json_items));
let json = serde_json::Value::Object(payload).to_string();

let content = format!("{prose}\n\n```buzz:todo-card\n{json}\n```");
check_content(&content, 64 * 1024)?;

let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
for assignee in &assignees {
tags.push(tag(&["p", assignee])?);
}
Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags))
}

/// Build a to-do card check-off response (kind 40009, NIP-TC).
///
/// `done: true` checks the item off; `false` un-checks the signer's own
/// completion. State is folded client-side: the latest response per
/// (item, pubkey) wins.
pub fn build_card_response(
channel_id: Uuid,
card_event_id: nostr::EventId,
item_id: &str,
done: bool,
) -> Result<EventBuilder, SdkError> {
if item_id.is_empty() {
return Err(SdkError::InvalidInput("item_id must be non-empty".into()));
}
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["e", &card_event_id.to_hex()])?,
tag(&["item", item_id])?,
];
Ok(EventBuilder::new(
Kind::Custom(KIND_CARD_RESPONSE as u16),
serde_json::json!({ "done": done }).to_string(),
)
.tags(tags))
}

/// Build a NIP-25 reaction event (kind 7). Emoji max 64 chars.
pub fn build_reaction(
target_event_id: nostr::EventId,
Expand Down Expand Up @@ -2239,6 +2330,95 @@ mod tests {
})
}

#[test]
fn todo_card_message_round_trips_fenced_payload() {
let cid = uuid();
let assignee = keys().public_key().to_hex();
let items = [
TodoCardItem {
id: "a1".into(),
text: "Tom: flip the flag".into(),
assignee: Some(assignee.clone()),
},
TodoCardItem {
id: "b2".into(),
text: "Anyone: verify dashboards".into(),
assignee: None,
},
];
let ev = sign(
build_todo_card_message(cid, "Launch checklist:", Some("Launch"), &items).unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "p", &assignee));
assert!(ev
.content
.starts_with("Launch checklist:\n\n```buzz:todo-card\n"));
assert!(ev.content.ends_with("\n```"));

// Round-trip: the fenced JSON parses back to the same card.
let json = ev
.content
.split("```buzz:todo-card\n")
.nth(1)
.and_then(|rest| rest.strip_suffix("\n```"))
.expect("fenced payload");
let parsed: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
assert_eq!(parsed["v"], 1);
assert_eq!(parsed["title"], "Launch");
assert_eq!(parsed["items"][0]["id"], "a1");
assert_eq!(parsed["items"][0]["assignee"], serde_json::json!(assignee));
assert_eq!(parsed["items"][1]["id"], "b2");
assert!(parsed["items"][1].get("assignee").is_none());
}

#[test]
fn todo_card_message_rejects_invalid_items() {
let cid = uuid();
assert!(build_todo_card_message(cid, "p", None, &[]).is_err());

let dup = |id: &str| TodoCardItem {
id: id.into(),
text: "x".into(),
assignee: None,
};
assert!(build_todo_card_message(cid, "p", None, &[dup("a"), dup("a")]).is_err());
assert!(build_todo_card_message(cid, "p", None, &[dup("")]).is_err());

let too_many: Vec<TodoCardItem> = (0..=MAX_TODO_CARD_ITEMS)
.map(|i| dup(&format!("item-{i}")))
.collect();
assert!(build_todo_card_message(cid, "p", None, &too_many).is_err());

let bad_assignee = [TodoCardItem {
id: "a".into(),
text: "x".into(),
assignee: Some("not-hex".into()),
}];
assert!(build_todo_card_message(cid, "p", None, &bad_assignee).is_err());
}

#[test]
fn card_response_happy_path() {
let cid = uuid();
let card = event_id();
let ev = sign(build_card_response(cid, card, "a1", true).unwrap());
assert_eq!(ev.kind.as_u16(), 40009);
assert!(has_tag(&ev, "h", &cid.to_string()));
assert!(has_tag(&ev, "e", &card.to_hex()));
assert!(has_tag(&ev, "item", "a1"));
assert_eq!(ev.content, r#"{"done":true}"#);

let uncheck = sign(build_card_response(cid, card, "a1", false).unwrap());
assert_eq!(uncheck.content, r#"{"done":false}"#);
}

#[test]
fn card_response_rejects_empty_item_id() {
assert!(build_card_response(uuid(), event_id(), "", true).is_err());
}

#[test]
fn message_happy_path() {
let cid = uuid();
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ pub struct DiffMeta {
pub alt_text: Option<String>,
}

/// One item in a `buzz:todo-card` interactive card message
/// (see `docs/nips/NIP-TC.md`).
pub struct TodoCardItem {
/// Card-unique item id, referenced by kind:40009 response `item` tags.
pub id: String,
/// Human-readable item text.
pub text: String,
/// Optional assignee pubkey (64-char hex). Absent = anyone may complete.
pub assignee: Option<String>,
}

/// Vote direction for `build_vote`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoteDirection {
Expand Down
Loading