From b99a327b495e286a64e25c00e938e45b5d9d1be3 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Thu, 23 Jul 2026 17:24:52 +0300 Subject: [PATCH] feat(huddle): add huddle-transcript signed event kind + SDK builder (#2513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A huddle's spoken content is the one modality that never becomes part of the event log — agents watching a channel can see that a huddle happened, but never what was said (#2513). This adds the protocol foundation for huddle transcripts as first-class, searchable, signed channel events. - buzz-core/kind.rs: new `KIND_HUDDLE_TRANSCRIPT = 48104`, in the huddle lifecycle range and registered in `ALL_KINDS`. - buzz-core/huddle.rs: `HuddleTranscriptSegment` — the typed reader side used by agents. A segment's *content is plain transcript text* (so it is FTS-indexed and readable like a message); structure is in tags: `h` channel, `p` speaker pubkey, `e` huddle-started event, `start`/`end` millisecond offsets, optional `lang`. `from_content_and_tags` parses one back out. - buzz-sdk/builders.rs: `build_huddle_transcript(...)` constructs the signed event with validation (hex speaker/huddle id, non-empty text, size cap, end >= start). - buzz-relay/ingest.rs: register the kind's write scope (`ChannelsWrite`) and `h`-channel scoping, alongside the other huddle events. Per-speaker attribution is inherent server-side: the audio room already keys participants by `(pubkey, peer_index)`, so a segment maps to a pubkey with no diarization. Because content is plain text, transcripts are searchable through the existing Postgres FTS `search_tsv` column with no extra indexer. Scope: this is the reviewable protocol slice. Server-side per-speaker recording (tap the audio room -> persist Opus per pubkey to buzz-media), the transcription pass over those tracks, and consent/retention (opt-in per huddle announced via KIND_HUDDLE_GUIDELINES; NIP-09 deletion for transcript events + object cleanup) are the documented follow-ups — see the PR body. Tested: buzz-core (5) + buzz-sdk (4) huddle tests pass, incl. a round-trip from the SDK builder through the core reader. Zero unsafe, no panics on new paths. Refs #2513 Signed-off-by: Taras Kornichuk --- crates/buzz-core/src/huddle.rs | 211 +++++++++++++++++++++++ crates/buzz-core/src/kind.rs | 8 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-relay/src/handlers/ingest.rs | 5 +- crates/buzz-sdk/src/builders.rs | 161 ++++++++++++++++- 5 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-core/src/huddle.rs diff --git a/crates/buzz-core/src/huddle.rs b/crates/buzz-core/src/huddle.rs new file mode 100644 index 0000000000..63b29a59d4 --- /dev/null +++ b/crates/buzz-core/src/huddle.rs @@ -0,0 +1,211 @@ +//! Huddle transcript segments — the typed view of `KIND_HUDDLE_TRANSCRIPT` +//! events (issue #2513). +//! +//! A huddle's spoken content is the one modality that never became part of the +//! event log: agents watching a channel could see that a huddle happened, but +//! not what was said. A transcript segment closes that gap. Each segment is a +//! signed channel event whose **content is the plain transcript text** — so it +//! is FTS-indexed and readable by agents exactly like a message — with the +//! structure carried in tags: +//! +//! ```text +//! kind: 48104 (KIND_HUDDLE_TRANSCRIPT) +//! content: "the quick brown fox" ← plain text, FTS-indexed +//! tags: ["h", ] ← channel scope (NIP-29 group) +//! ["p", ] ← attribution (the relay knows who spoke) +//! ["e", ]← links the segment to its huddle session +//! ["start", ] ← offset from huddle start, milliseconds +//! ["end", ] ← offset from huddle start, milliseconds +//! ["lang", ] ← optional detected/declared language +//! ``` +//! +//! Per-speaker attribution is inherent server-side: the audio room keys every +//! participant by `(pubkey, peer_index)`, so a segment maps to a pubkey with no +//! diarization guesswork. The signed-event builder lives in `buzz-sdk` +//! (`build_huddle_transcript`); this module is the reader side used by agents +//! and any consumer that wants a typed segment back out of the log. + +use uuid::Uuid; + +/// Tag key: millisecond start offset from the huddle start. +pub const TAG_START_MS: &str = "start"; +/// Tag key: millisecond end offset from the huddle start. +pub const TAG_END_MS: &str = "end"; +/// Tag key: optional BCP-47 language of the segment (e.g. `"en"`, `"uk"`). +pub const TAG_LANG: &str = "lang"; +/// Tag key: channel scope (NIP-29 group id). +pub const TAG_CHANNEL: &str = "h"; +/// Tag key: speaker pubkey (attribution). +pub const TAG_SPEAKER: &str = "p"; +/// Tag key: the `KIND_HUDDLE_STARTED` event id this segment belongs to. +pub const TAG_HUDDLE_EVENT: &str = "e"; + +/// One attributed transcript segment: who spoke, what they said, and when. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HuddleTranscriptSegment { + /// Channel (NIP-29 group) the huddle belongs to — from the `h` tag. + pub channel_id: Uuid, + /// Hex pubkey of the speaker — from the `p` tag. + pub speaker_pubkey: String, + /// The `KIND_HUDDLE_STARTED` event id, when present — from the `e` tag. + pub huddle_event_id: Option, + /// Plain transcript text — the event content. + pub text: String, + /// Segment start, milliseconds from huddle start — from the `start` tag. + pub start_ms: u64, + /// Segment end, milliseconds from huddle start — from the `end` tag. + pub end_ms: u64, + /// Detected/declared language (BCP-47), when present — from the `lang` tag. + pub language: Option, +} + +impl HuddleTranscriptSegment { + /// Parse a segment from an event's `content` and raw string tags. + /// + /// `tags` is a slice of raw tag arrays (`["h", ""]`, …) — the shape + /// yielded by `event.tags.iter().map(|t| t.as_slice())`. Kept free of the + /// `nostr` event type so it is trivially testable and reusable. + /// + /// Returns `None` when a required tag (`h`, `p`, `start`, `end`) is missing + /// or malformed, or when `end_ms < start_ms`. Content is taken verbatim + /// (empty is allowed at the type level; the builder rejects it on write). + pub fn from_content_and_tags>(content: &str, tags: &[&[S]]) -> Option { + let find = |key: &str| -> Option<&str> { + tags.iter().find_map(|t| { + let k = t.first()?.as_ref(); + if k == key { + Some(t.get(1)?.as_ref()) + } else { + None + } + }) + }; + + let channel_id = find(TAG_CHANNEL)?.parse::().ok()?; + let speaker_pubkey = find(TAG_SPEAKER)?.to_string(); + let start_ms = find(TAG_START_MS)?.parse::().ok()?; + let end_ms = find(TAG_END_MS)?.parse::().ok()?; + if end_ms < start_ms { + return None; + } + let huddle_event_id = find(TAG_HUDDLE_EVENT).map(str::to_string); + let language = find(TAG_LANG).map(str::to_string); + + Some(Self { + channel_id, + speaker_pubkey, + huddle_event_id, + text: content.to_string(), + start_ms, + end_ms, + language, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PK: &str = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; + const HUDDLE: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + + fn tags(pairs: &[[&str; 2]]) -> Vec> { + pairs + .iter() + .map(|[k, v]| vec![k.to_string(), v.to_string()]) + .collect() + } + + fn as_slices(tags: &[Vec]) -> Vec<&[String]> { + tags.iter().map(Vec::as_slice).collect() + } + + #[test] + fn parses_a_full_segment() { + let cid = Uuid::new_v4(); + let t = tags(&[ + [TAG_CHANNEL, &cid.to_string()], + [TAG_SPEAKER, PK], + [TAG_HUDDLE_EVENT, HUDDLE], + [TAG_START_MS, "1200"], + [TAG_END_MS, "3400"], + [TAG_LANG, "uk"], + ]); + let seg = HuddleTranscriptSegment::from_content_and_tags("привіт світ", &as_slices(&t)) + .expect("parses"); + assert_eq!(seg.channel_id, cid); + assert_eq!(seg.speaker_pubkey, PK); + assert_eq!(seg.huddle_event_id.as_deref(), Some(HUDDLE)); + assert_eq!(seg.text, "привіт світ"); + assert_eq!(seg.start_ms, 1200); + assert_eq!(seg.end_ms, 3400); + assert_eq!(seg.language.as_deref(), Some("uk")); + } + + #[test] + fn optional_tags_may_be_absent() { + let cid = Uuid::new_v4(); + let t = tags(&[ + [TAG_CHANNEL, &cid.to_string()], + [TAG_SPEAKER, PK], + [TAG_START_MS, "0"], + [TAG_END_MS, "500"], + ]); + let seg = + HuddleTranscriptSegment::from_content_and_tags("hi", &as_slices(&t)).expect("parses"); + assert_eq!(seg.huddle_event_id, None); + assert_eq!(seg.language, None); + } + + #[test] + fn missing_required_tag_is_none() { + let cid = Uuid::new_v4(); + // No speaker `p` tag. + let t = tags(&[ + [TAG_CHANNEL, &cid.to_string()], + [TAG_START_MS, "0"], + [TAG_END_MS, "1"], + ]); + assert!(HuddleTranscriptSegment::from_content_and_tags("hi", &as_slices(&t)).is_none()); + } + + #[test] + fn malformed_values_are_none() { + let cid = Uuid::new_v4(); + // Non-numeric start. + let bad_start = tags(&[ + [TAG_CHANNEL, &cid.to_string()], + [TAG_SPEAKER, PK], + [TAG_START_MS, "soon"], + [TAG_END_MS, "1"], + ]); + assert!( + HuddleTranscriptSegment::from_content_and_tags("hi", &as_slices(&bad_start)).is_none() + ); + + // Non-uuid channel. + let bad_channel = tags(&[ + [TAG_CHANNEL, "not-a-uuid"], + [TAG_SPEAKER, PK], + [TAG_START_MS, "0"], + [TAG_END_MS, "1"], + ]); + assert!( + HuddleTranscriptSegment::from_content_and_tags("hi", &as_slices(&bad_channel)) + .is_none() + ); + } + + #[test] + fn end_before_start_is_rejected() { + let cid = Uuid::new_v4(); + let t = tags(&[ + [TAG_CHANNEL, &cid.to_string()], + [TAG_SPEAKER, PK], + [TAG_START_MS, "5000"], + [TAG_END_MS, "1000"], + ]); + assert!(HuddleTranscriptSegment::from_content_and_tags("hi", &as_slices(&t)).is_none()); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..420373a012 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -458,6 +458,13 @@ pub const KIND_HUDDLE_PARTICIPANT_JOINED: u32 = 48101; pub const KIND_HUDDLE_PARTICIPANT_LEFT: u32 = 48102; /// A huddle ended. pub const KIND_HUDDLE_ENDED: u32 = 48103; +/// A huddle transcript segment — attributed, plain-text speech from one speaker +/// over one time range, emitted as a signed channel event so agents can read +/// and search what was said in a huddle (issue #2513). Content is the plain +/// transcript text (FTS-indexed like a message); `p` tags the speaker pubkey, +/// `h` the channel, `e` the `KIND_HUDDLE_STARTED` event, and `start`/`end` the +/// millisecond offsets from huddle start. See `buzz_core::huddle`. +pub const KIND_HUDDLE_TRANSCRIPT: u32 = 48104; /// Huddle channel guidelines/rules document. pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; @@ -603,6 +610,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, + KIND_HUDDLE_TRANSCRIPT, KIND_HUDDLE_GUIDELINES, KIND_MEDIA_UPLOAD, KIND_GIT_REPO_ANNOUNCEMENT, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dd57b46937..fe46b2466b 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Huddle transcript segments — typed reader for `KIND_HUDDLE_TRANSCRIPT`. +pub mod huddle; /// Buzz kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index f53ea0f018..54b734dd33 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -20,7 +20,8 @@ use buzz_core::kind::{ 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_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRANSCRIPT, + 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, @@ -285,6 +286,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), @@ -479,6 +481,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_HUDDLE_PARTICIPANT_JOINED | KIND_HUDDLE_PARTICIPANT_LEFT | KIND_HUDDLE_ENDED + | KIND_HUDDLE_TRANSCRIPT | KIND_HUDDLE_GUIDELINES ) } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index e52c97603c..e1295111f3 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -9,10 +9,10 @@ use buzz_core::{ 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, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_GIT_STATUS_OPEN, KIND_HUDDLE_TRANSCRIPT, KIND_IA_ARCHIVE_REQUEST, + KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, + KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, + KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1542,6 +1542,68 @@ pub fn build_dm_add_member(channel_id: Uuid, pubkey: &str) -> Result, +) -> Result { + let speaker = check_pubkey_hex(speaker_pubkey, "speaker_pubkey")?; + if huddle_event_id.len() != 64 || !huddle_event_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput( + "huddle_event_id must be a 64-character hex event id".into(), + )); + } + if text.trim().is_empty() { + return Err(SdkError::InvalidInput( + "transcript text must not be empty".into(), + )); + } + check_content(text, MAX_TRANSCRIPT_BYTES)?; + if end_ms < start_ms { + return Err(SdkError::InvalidInput( + "transcript end_ms must be >= start_ms".into(), + )); + } + + let mut tags = vec![ + tag(&["h", &channel_id.to_string()])?, + tag(&["p", &speaker])?, + tag(&["e", huddle_event_id])?, + tag(&["start", &start_ms.to_string()])?, + tag(&["end", &end_ms.to_string()])?, + ]; + if let Some(lang) = language { + if !lang.is_empty() { + tags.push(tag(&["lang", lang])?); + } + } + Ok(EventBuilder::new(Kind::Custom(KIND_HUDDLE_TRANSCRIPT as u16), text).tags(tags)) +} + /// Build a presence update event (kind 20001). /// /// `status` must be one of: `"online"`, `"away"`, `"offline"`. @@ -2000,6 +2062,97 @@ mod tests { assert!(build_message(cid, &max, None, &[], false, &[]).is_ok()); } + const SPEAKER_HEX: &str = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; + + #[test] + fn huddle_transcript_happy_path() { + let cid = uuid(); + let huddle = event_id().to_hex(); + let ev = sign( + build_huddle_transcript( + cid, + SPEAKER_HEX, + &huddle, + "hello world", + 1200, + 3400, + Some("en"), + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 48104); + assert_eq!(ev.content, "hello world"); + assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "p", SPEAKER_HEX)); + assert!(has_tag(&ev, "e", &huddle)); + assert!(has_tag(&ev, "start", "1200")); + assert!(has_tag(&ev, "end", "3400")); + assert!(has_tag(&ev, "lang", "en")); + } + + #[test] + fn huddle_transcript_roundtrips_through_core_reader() { + use buzz_core::huddle::HuddleTranscriptSegment; + let cid = uuid(); + let huddle = event_id().to_hex(); + let ev = sign( + build_huddle_transcript(cid, SPEAKER_HEX, &huddle, "привіт", 0, 900, Some("uk")) + .unwrap(), + ); + let raw: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + let slices: Vec<&[String]> = raw.iter().map(Vec::as_slice).collect(); + let seg = HuddleTranscriptSegment::from_content_and_tags(&ev.content, &slices) + .expect("reads back"); + assert_eq!(seg.channel_id, cid); + assert_eq!(seg.speaker_pubkey, SPEAKER_HEX); + assert_eq!(seg.huddle_event_id.as_deref(), Some(huddle.as_str())); + assert_eq!(seg.text, "привіт"); + assert_eq!(seg.start_ms, 0); + assert_eq!(seg.end_ms, 900); + assert_eq!(seg.language.as_deref(), Some("uk")); + } + + #[test] + fn huddle_transcript_lang_optional() { + let cid = uuid(); + let huddle = event_id().to_hex(); + let ev = + sign(build_huddle_transcript(cid, SPEAKER_HEX, &huddle, "hi", 0, 1, None).unwrap()); + assert!(tag_values(&ev, "lang").is_empty()); + } + + #[test] + fn huddle_transcript_rejects_bad_input() { + let cid = uuid(); + let huddle = event_id().to_hex(); + // Empty / whitespace-only text. + assert!(matches!( + build_huddle_transcript(cid, SPEAKER_HEX, &huddle, " ", 0, 1, None), + Err(SdkError::InvalidInput(_)) + )); + // Malformed speaker pubkey. + assert!(matches!( + build_huddle_transcript(cid, "xyz", &huddle, "hi", 0, 1, None), + Err(SdkError::InvalidInput(_)) + )); + // Malformed huddle event id. + assert!(matches!( + build_huddle_transcript(cid, SPEAKER_HEX, "short", "hi", 0, 1, None), + Err(SdkError::InvalidInput(_)) + )); + // end before start. + assert!(matches!( + build_huddle_transcript(cid, SPEAKER_HEX, &huddle, "hi", 5, 1, None), + Err(SdkError::InvalidInput(_)) + )); + // Content over the cap. + let big = "x".repeat(16 * 1024 + 1); + assert!(matches!( + build_huddle_transcript(cid, SPEAKER_HEX, &huddle, &big, 0, 1, None), + Err(SdkError::ContentTooLarge { .. }) + )); + } + #[test] fn forum_post_happy_path() { let cid = uuid();