Skip to content

Commit dd6107d

Browse files
feat: Sprout Huddles — voice conversations with AI agents (#299)
1 parent 08be364 commit dd6107d

50 files changed

Lines changed: 9367 additions & 296 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
build-essential \
6767
curl \
6868
file \
69+
libasound2-dev \
6970
libayatana-appindicator3-dev \
7071
libgtk-3-dev \
7172
librsvg2-dev \

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sprout-core/src/kind.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ pub const KIND_HUDDLE_ENDED: u32 = 48103;
219219
pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104;
220220
/// A huddle recording became available.
221221
pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105;
222+
/// Huddle channel guidelines/rules document.
223+
pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
222224

223225
// Media (49000–49999)
224226
/// Internal kind for media upload audit entries. Not a relay event kind.

crates/sprout-huddle/Cargo.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ chrono = { workspace = true }
1818
tracing = { workspace = true }
1919
thiserror = { workspace = true }
2020
jsonwebtoken = { workspace = true }
21-
hmac = { workspace = true }
22-
sha2 = { workspace = true }
23-
hex = { workspace = true }
21+
hmac = { workspace = true, optional = true }
22+
sha2 = { workspace = true, optional = true }
23+
hex = { workspace = true, optional = true }
24+
25+
[features]
26+
default = []
27+
webhook = ["hmac", "sha2", "hex"]

crates/sprout-huddle/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,24 @@
99
/// Error types for the huddle layer.
1010
pub mod error;
1111
/// In-memory huddle session and participant tracking.
12+
#[cfg(feature = "webhook")]
1213
pub mod session;
1314
/// LiveKit access token generation.
1415
pub mod token;
1516
/// LiveKit webhook signature verification and event parsing.
17+
#[cfg(feature = "webhook")]
1618
pub mod webhook;
1719

1820
pub use error::HuddleError;
21+
#[cfg(feature = "webhook")]
1922
pub use session::{HuddleParticipant, HuddleSession, TrackInfo, TrackKind};
2023
pub use token::LiveKitToken;
24+
#[cfg(feature = "webhook")]
2125
pub use webhook::WebhookEvent;
2226

2327
use uuid::Uuid;
2428

29+
#[cfg(feature = "webhook")]
2530
pub use sprout_core::kind::{
2631
KIND_HUDDLE_ENDED, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT,
2732
KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED,
@@ -74,6 +79,7 @@ impl HuddleService {
7479
}
7580

7681
/// Verify the webhook signature and parse the LiveKit event payload.
82+
#[cfg(feature = "webhook")]
7783
pub fn parse_webhook(
7884
&self,
7985
body: &[u8],
@@ -146,6 +152,7 @@ mod tests {
146152
}
147153

148154
#[test]
155+
#[cfg(feature = "webhook")]
149156
fn session_lifecycle() {
150157
let channel_id = Uuid::new_v4();
151158
let room_name = HuddleService::create_room_name(channel_id);

crates/sprout-huddle/src/token.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub fn generate_token(
5050
ttl: Option<Duration>,
5151
) -> Result<LiveKitToken, HuddleError> {
5252
let now = Utc::now();
53-
let ttl = ttl.unwrap_or_else(|| Duration::hours(6));
53+
let ttl = ttl.unwrap_or_else(|| Duration::hours(1));
5454
let expires_at = now + ttl;
5555

5656
let claims = LiveKitClaims {

crates/sprout-relay/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ deadpool-redis = { workspace = true }
3838
redis = { workspace = true }
3939
sqlx = { workspace = true }
4040
base64 = "0.22"
41+
sprout-huddle = { workspace = true, features = ["webhook"] }
4142
sprout-workflow = { workspace = true, features = ["reqwest"] }
4243
sprout-media = { workspace = true }
4344
bytes = "1"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! LiveKit huddle token endpoint.
2+
//!
3+
//! ## Routes
4+
//! - `POST /api/huddles/{channel_id}/token` — generate a LiveKit access token
5+
//! for the authenticated user to join the channel's huddle room.
6+
7+
use std::sync::Arc;
8+
9+
use axum::{
10+
extract::{Path, Query, State},
11+
http::{HeaderMap, StatusCode},
12+
response::Json,
13+
};
14+
use uuid::Uuid;
15+
16+
use super::{
17+
check_channel_membership, check_token_channel_access, extract_auth_context, internal_error,
18+
scope_error,
19+
};
20+
use crate::state::AppState;
21+
22+
/// Query parameters for [`huddle_token`].
23+
#[derive(serde::Deserialize)]
24+
pub struct HuddleTokenQuery {
25+
/// The parent (non-ephemeral) channel this huddle belongs to.
26+
///
27+
/// When provided and the caller is a member of the parent channel, the relay
28+
/// will auto-add them to the private ephemeral huddle channel so they can
29+
/// obtain a token without requiring an explicit invite.
30+
pub parent_channel_id: Option<Uuid>,
31+
}
32+
33+
/// `POST /api/huddles/{channel_id}/token` — generate a LiveKit access token.
34+
///
35+
/// Returns `{ "token": "<jwt>", "url": "<livekit_url>", "room": "sprout-<channel_id>" }`.
36+
/// Returns 501 if LiveKit is not configured on this relay.
37+
/// Returns 403 if the caller is not a member of the channel.
38+
pub async fn huddle_token(
39+
State(state): State<Arc<AppState>>,
40+
headers: HeaderMap,
41+
Path(channel_id): Path<Uuid>,
42+
Query(query): Query<HuddleTokenQuery>,
43+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
44+
let ctx = extract_auth_context(&headers, &state).await?;
45+
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
46+
.map_err(scope_error)?;
47+
check_token_channel_access(&ctx, &channel_id)?;
48+
49+
// Require LiveKit to be configured.
50+
let huddle_service = state.huddle_service.as_ref().ok_or_else(|| {
51+
(
52+
StatusCode::NOT_IMPLEMENTED,
53+
Json(serde_json::json!({
54+
"error": "huddles_not_configured",
55+
"message": "LiveKit is not configured on this relay"
56+
})),
57+
)
58+
})?;
59+
60+
// Verify the caller is a member of the channel (or it's an open channel).
61+
// If they're not a member, attempt relay-side auto-add when:
62+
// 1. parent_channel_id was provided
63+
// 2. The target channel is private + ephemeral (ttl_seconds IS NOT NULL)
64+
// 3. The caller IS a member of the parent channel
65+
let membership_result = check_channel_membership(&state, channel_id, &ctx.pubkey_bytes).await;
66+
67+
if let Err(ref membership_err) = membership_result {
68+
if let Some(parent_id) = query.parent_channel_id {
69+
// Gate 1: caller must be a member of the parent channel.
70+
check_channel_membership(&state, parent_id, &ctx.pubkey_bytes).await?;
71+
72+
// Gate 2: target channel must be private + ephemeral.
73+
let channel = state
74+
.db
75+
.get_channel(channel_id)
76+
.await
77+
.map_err(|e| internal_error(&format!("db error: {e}")))?;
78+
79+
if channel.visibility == "private" && channel.ttl_seconds.is_some() {
80+
// Auto-add: use the channel creator as invited_by (truthful attribution).
81+
// The creator is always an active owner, satisfying add_member's invite check.
82+
state
83+
.db
84+
.add_member(
85+
channel_id,
86+
&ctx.pubkey_bytes,
87+
sprout_db::channel::MemberRole::Member,
88+
Some(&channel.created_by),
89+
)
90+
.await
91+
.map_err(|e| internal_error(&format!("auto-add failed: {e}")))?;
92+
93+
tracing::info!(
94+
"Huddle auto-add: added {} to ephemeral channel {} (parent: {})",
95+
ctx.pubkey.to_hex(),
96+
channel_id,
97+
parent_id
98+
);
99+
// Fall through to token generation.
100+
} else {
101+
// Not a private ephemeral channel — return the original 403.
102+
return Err(membership_err.clone());
103+
}
104+
} else {
105+
// No parent_channel_id provided — return the original 403.
106+
membership_result?;
107+
}
108+
}
109+
110+
// Generate the LiveKit token.
111+
let room = sprout_huddle::HuddleService::create_room_name(channel_id);
112+
let identity = ctx.pubkey.to_hex();
113+
let lk_token = huddle_service
114+
.generate_token(&room, &identity, &identity)
115+
.map_err(|e| internal_error(&format!("token generation failed: {e}")))?;
116+
117+
let livekit_url = state.livekit_url.as_deref().unwrap_or_default();
118+
119+
if livekit_url.is_empty() {
120+
return Err(internal_error("livekit_url is not configured"));
121+
}
122+
123+
Ok(Json(serde_json::json!({
124+
"token": lk_token.token,
125+
"url": livekit_url,
126+
"room": room,
127+
})))
128+
}

crates/sprout-relay/src/api/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub mod dms;
2626
pub mod events;
2727
/// Personalized home feed endpoint.
2828
pub mod feed;
29+
/// LiveKit huddle token endpoint.
30+
pub mod huddles;
2931
/// Blossom-compatible media upload, retrieval, and existence check endpoints.
3032
pub mod media;
3133
/// Channel membership endpoints.
@@ -44,6 +46,8 @@ pub mod search;
4446
pub mod tokens;
4547
/// User profile endpoints.
4648
pub mod users;
49+
/// LiveKit webhook handler for server-side presence tracking.
50+
pub mod webhooks;
4751
/// Shared helpers for workflow API handlers.
4852
pub mod workflow_helpers;
4953
/// Workflow CRUD, trigger, and webhook endpoints.
@@ -58,6 +62,7 @@ pub use channels_metadata::get_channel_handler;
5862
pub use dms::{add_dm_member_handler, hide_dm_handler, list_dms_handler, open_dm_handler};
5963
pub use events::get_event;
6064
pub use feed::feed_handler;
65+
pub use huddles::huddle_token;
6166
pub use members::list_members;
6267
pub use messages::{get_thread, list_messages, validate_imeta_tags, verify_imeta_blobs};
6368
pub use presence::{presence_handler, set_presence_handler};
@@ -67,6 +72,7 @@ pub use users::{
6772
get_contact_list, get_profile, get_user_notes, get_user_profile, get_users_batch,
6873
put_channel_add_policy, search_users,
6974
};
75+
pub use webhooks::handle_livekit_webhook;
7076
pub use workflows::{
7177
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals,
7278
list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! LiveKit webhook handler — server-side huddle presence tracking.
2+
//!
3+
//! Receives webhook events from LiveKit and emits corresponding Nostr
4+
//! huddle lifecycle events (kinds 48100–48103). This provides authoritative
5+
//! presence tracking that survives client crashes — LiveKit fires
6+
//! `participant_left` when the WebRTC connection drops, regardless of
7+
//! whether the client performed a graceful shutdown.
8+
//!
9+
//! ## Route
10+
//! `POST /internal/livekit/webhook` — internal only, not exposed through
11+
//! the public API gateway.
12+
13+
use std::sync::Arc;
14+
15+
use axum::{
16+
extract::State,
17+
http::{HeaderMap, StatusCode},
18+
};
19+
use sprout_huddle::WebhookEvent;
20+
21+
use crate::state::AppState;
22+
23+
/// Handle a LiveKit webhook event.
24+
///
25+
/// Verifies the webhook signature, parses the event, and emits the
26+
/// corresponding Nostr huddle lifecycle event to the parent channel.
27+
///
28+
/// Returns 200 on success (even if the event type is unrecognized —
29+
/// LiveKit expects 2xx for all webhook deliveries).
30+
/// Returns 401 if signature verification fails.
31+
/// Returns 501 if huddles are not configured.
32+
pub async fn handle_livekit_webhook(
33+
State(state): State<Arc<AppState>>,
34+
headers: HeaderMap,
35+
body: String,
36+
) -> StatusCode {
37+
let huddle_service = match state.huddle_service.as_ref() {
38+
Some(svc) => svc,
39+
None => return StatusCode::NOT_IMPLEMENTED,
40+
};
41+
42+
// Verify signature and parse the event.
43+
let auth_header = headers
44+
.get("Authorization")
45+
.and_then(|v| v.to_str().ok())
46+
.unwrap_or("");
47+
48+
let event = match huddle_service.parse_webhook(body.as_bytes(), auth_header) {
49+
Ok(e) => e,
50+
Err(sprout_huddle::HuddleError::InvalidWebhookSignature) => {
51+
tracing::warn!("LiveKit webhook: signature verification failed");
52+
return StatusCode::UNAUTHORIZED;
53+
}
54+
Err(e) => {
55+
// Signed but malformed/unknown — log and return 200 so LiveKit
56+
// doesn't retry. Parse failures are not auth failures.
57+
tracing::warn!("LiveKit webhook: parse error (signed OK): {e}");
58+
return StatusCode::OK;
59+
}
60+
};
61+
62+
// Dispatch on the parsed enum variant.
63+
// Room names follow the format "sprout-{channel_uuid}".
64+
match event {
65+
WebhookEvent::RoomStarted { room } => {
66+
tracing::info!("LiveKit: room started {room}");
67+
// TODO: Emit kind:48100 signed by relay keypair
68+
}
69+
WebhookEvent::RoomFinished { room } => {
70+
tracing::info!("LiveKit: room finished {room}");
71+
// TODO: Emit kind:48103 + archive ephemeral channel
72+
}
73+
WebhookEvent::ParticipantJoined { room, identity } => {
74+
let Some(channel_id) = parse_channel_id(&room) else {
75+
tracing::debug!("LiveKit webhook for non-sprout room or invalid UUID: {room}");
76+
return StatusCode::OK;
77+
};
78+
tracing::info!(
79+
"LiveKit: participant joined room {room} (channel {channel_id}), identity={identity}"
80+
);
81+
// TODO: Emit kind:48101 signed by relay keypair.
82+
// The client also emits this event; the server-side copy provides
83+
// crash-recovery redundancy.
84+
}
85+
WebhookEvent::ParticipantLeft { room, identity } => {
86+
let Some(channel_id) = parse_channel_id(&room) else {
87+
tracing::debug!("LiveKit webhook for non-sprout room or invalid UUID: {room}");
88+
return StatusCode::OK;
89+
};
90+
tracing::info!(
91+
"LiveKit: participant left room {room} (channel {channel_id}), identity={identity}"
92+
);
93+
// TODO: Emit kind:48102 signed by relay keypair.
94+
// This is the key crash-recovery path — fires even if the client crashed.
95+
}
96+
WebhookEvent::TrackPublished {
97+
room,
98+
identity,
99+
kind,
100+
} => {
101+
tracing::debug!("LiveKit: track published in {room} by {identity} (kind={kind})");
102+
// TODO: Emit kind:48104 (track published) if/when that event kind is defined.
103+
}
104+
}
105+
106+
StatusCode::OK
107+
}
108+
109+
/// Extract the channel UUID from a LiveKit room name.
110+
///
111+
/// Room names follow the format `sprout-{uuid}`. Returns `None` if the name
112+
/// does not match the expected prefix or contains an invalid UUID.
113+
fn parse_channel_id(room_name: &str) -> Option<uuid::Uuid> {
114+
let channel_id = room_name.strip_prefix("sprout-")?;
115+
uuid::Uuid::parse_str(channel_id)
116+
.map_err(|_| {
117+
tracing::warn!("LiveKit webhook: invalid channel UUID in room name: {room_name}");
118+
})
119+
.ok()
120+
}

0 commit comments

Comments
 (0)