Sanntidslyd: kommunikasjonsnode → LiveKit-rom (oppgave 11.2)
Kobler kommunikasjonsnoder til LiveKit for sanntidslyd. Bruker sender join_communication-intensjon, maskinrommet validerer tilgang og returnerer signert LiveKit JWT-token + rom-URL. Nye komponenter: - maskinrommet/src/livekit.rs: JWT token-generering (HS256-signert med LIVEKIT_API_SECRET, 1-times TTL, publisher/subscriber-roller) - POST /intentions/join_communication: validerer deltaker-edge, genererer token, oppretter rom i STDB, oppdaterer node-metadata - POST /intentions/leave_communication: fjerner deltaker fra STDB - POST /intentions/close_communication: stenger rom (krever owner) - SpacetimeDB: live_room + room_participant tabeller for sanntids deltakerliste (frontend abonnerer via WebSocket) SpacetimeDB-modul publisert som synops-v2 (ny identitet etter at den opprinnelige ikke lenger var tilgjengelig). .env oppdatert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5392560807
commit
445f32de69
8 changed files with 756 additions and 2 deletions
|
|
@ -82,6 +82,27 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
|
|||
- Body (JSON): `{ node_id }`
|
||||
- Respons: `{ deleted: true }`
|
||||
|
||||
### LiveKit / Sanntidslyd (oppgave 11.2)
|
||||
- `POST /intentions/join_communication` — Koble til sanntidslyd i en kommunikasjonsnode.
|
||||
Validerer deltaker-tilgang (owner/member_of/host_of-edge eller via alias).
|
||||
Genererer LiveKit access token (JWT), oppretter rom i STDB, oppdaterer node-metadata.
|
||||
- Body (JSON): `{ communication_id, role? }` (role: "publisher" | "subscriber", default "publisher")
|
||||
- Respons: `{ livekit_room_name, livekit_token, livekit_url, identity, participants[] }`
|
||||
- Frontend bruker `livekit_token` + `livekit_url` til å koble livekit-client SDK.
|
||||
- `POST /intentions/leave_communication` — Forlat sanntidsrom.
|
||||
Fjerner deltaker fra STDB live-rom.
|
||||
- Body (JSON): `{ communication_id }`
|
||||
- Respons: `{ status: "left" }`
|
||||
- `POST /intentions/close_communication` — Steng sanntidsrom (krever owner/admin).
|
||||
Fjerner alle deltakere, setter `live_status=ended` i metadata.
|
||||
- Body (JSON): `{ communication_id }`
|
||||
- Respons: `{ status: "closed" }`
|
||||
|
||||
### SpacetimeDB sanntidstabeller (LiveKit)
|
||||
- `live_room` — Aktive rom. Felt: `room_id`, `communication_id`, `is_active`, `started_at`, `participant_count`.
|
||||
- `room_participant` — Deltakere i rom. Felt: `id`, `room_id`, `user_id`, `display_name`, `role`, `joined_at`.
|
||||
Frontend abonnerer på disse via SpacetimeDB WebSocket for sanntids deltakerliste.
|
||||
|
||||
## 6. Instruks for Claude Code
|
||||
|
||||
- Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use sqlx::PgPool;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::livekit;
|
||||
use crate::AppState;
|
||||
|
||||
/// Maks filstørrelse for upload: 100 MB.
|
||||
|
|
@ -2319,3 +2320,392 @@ pub async fn generate_tts(
|
|||
|
||||
Ok(Json(GenerateTtsResponse { job_id }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LiveKit — Join/Leave Communication
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinCommunicationRequest {
|
||||
pub communication_id: Uuid,
|
||||
/// "publisher" (kan sende lyd) eller "subscriber" (bare lytte).
|
||||
/// Default: "publisher".
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct JoinCommunicationResponse {
|
||||
pub livekit_room_name: String,
|
||||
pub livekit_token: String,
|
||||
pub livekit_url: String,
|
||||
pub identity: String,
|
||||
pub participants: Vec<RoomParticipantInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RoomParticipantInfo {
|
||||
pub user_id: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// POST /intentions/join_communication
|
||||
///
|
||||
/// Kobler en bruker til sanntidslyd i en kommunikasjonsnode.
|
||||
/// Validerer tilgang (bruker må ha member_of/owner/host_of-edge),
|
||||
/// genererer LiveKit-token, oppdaterer STDB med live-status.
|
||||
pub async fn join_communication(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<JoinCommunicationRequest>,
|
||||
) -> Result<Json<JoinCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let comm_id = req.communication_id;
|
||||
let comm_id_str = comm_id.to_string();
|
||||
|
||||
// Sjekk at kommunikasjonsnoden eksisterer og er riktig type
|
||||
let node_row = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT node_kind, title FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved nodesjekk: {e}");
|
||||
internal_error("Databasefeil ved validering")
|
||||
})?;
|
||||
|
||||
let (node_kind, _title) = match node_row {
|
||||
Some(row) => row,
|
||||
None => return Err(bad_request("Kommunikasjonsnode finnes ikke")),
|
||||
};
|
||||
|
||||
if node_kind != "communication" {
|
||||
return Err(bad_request(&format!(
|
||||
"Node er type '{node_kind}', ikke 'communication'"
|
||||
)));
|
||||
}
|
||||
|
||||
// Sjekk at brukeren har tilgang (direkte eller via alias)
|
||||
let has_access = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
-- Direkte edge: bruker → kommunikasjon
|
||||
SELECT 1 FROM edges
|
||||
WHERE source_id = $2 AND target_id = $1
|
||||
AND edge_type IN ('owner', 'member_of', 'host_of')
|
||||
) OR EXISTS(
|
||||
-- Via alias: bruker --alias--> alias --member_of/etc--> kommunikasjon
|
||||
SELECT 1 FROM edges e_alias
|
||||
JOIN edges e_member ON e_member.source_id = e_alias.target_id
|
||||
WHERE e_alias.source_id = $2
|
||||
AND e_alias.edge_type = 'alias'
|
||||
AND e_alias.system = true
|
||||
AND e_member.target_id = $1
|
||||
AND e_member.edge_type IN ('owner', 'member_of', 'host_of')
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(comm_id)
|
||||
.bind(user.node_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||||
internal_error("Databasefeil ved tilgangssjekk")
|
||||
})?;
|
||||
|
||||
if !has_access {
|
||||
return Err(forbidden("Ingen tilgang til denne kommunikasjonsnoden"));
|
||||
}
|
||||
|
||||
// Resolve display name (alias or user title)
|
||||
let context_identity = resolve_context_identity(&state.db, user.node_id, comm_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Kunne ikke resolve context identity: {e}");
|
||||
internal_error("Kunne ikke hente brukeridentitet")
|
||||
})?;
|
||||
|
||||
let display_name = sqlx::query_scalar::<_, String>(
|
||||
"SELECT title FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(context_identity)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved navnoppslag: {e}");
|
||||
internal_error("Databasefeil")
|
||||
})?
|
||||
.unwrap_or_else(|| "Ukjent".to_string());
|
||||
|
||||
// Bestem rolle
|
||||
let role_str = req.role.as_deref().unwrap_or("publisher");
|
||||
let lk_role = match role_str {
|
||||
"subscriber" => livekit::RoomRole::Subscriber,
|
||||
_ => livekit::RoomRole::Publisher,
|
||||
};
|
||||
|
||||
// Generer LiveKit-token
|
||||
let token_result = livekit::generate_token(
|
||||
comm_id,
|
||||
user.node_id,
|
||||
&display_name,
|
||||
lk_role,
|
||||
3600, // 1 time
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("LiveKit token-generering feilet: {e}");
|
||||
internal_error(&e)
|
||||
})?;
|
||||
|
||||
let room_name = token_result.room_name.clone();
|
||||
|
||||
// Oppdater SpacetimeDB: opprett rom (idempotent) + legg til deltaker
|
||||
if let Err(e) = state.stdb.create_live_room(&room_name, &comm_id_str).await {
|
||||
tracing::warn!("STDB create_live_room feilet (fortsetter): {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = state
|
||||
.stdb
|
||||
.add_room_participant(
|
||||
&room_name,
|
||||
&user.node_id.to_string(),
|
||||
&display_name,
|
||||
role_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("STDB add_room_participant feilet (fortsetter): {e}");
|
||||
}
|
||||
|
||||
// Oppdater kommunikasjonsnodens metadata med live_status (asynkront)
|
||||
let db = state.db.clone();
|
||||
let stdb = state.stdb.clone();
|
||||
let comm_id_clone = comm_id;
|
||||
let room_name_clone = room_name.clone();
|
||||
tokio::spawn(async move {
|
||||
// Les eksisterende metadata, legg til live_status
|
||||
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
||||
"SELECT metadata FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id_clone)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(mut metadata)) = result {
|
||||
if let Some(obj) = metadata.as_object_mut() {
|
||||
obj.insert("live_status".into(), "active".into());
|
||||
obj.insert("livekit_room_name".into(), room_name_clone.clone().into());
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE nodes SET metadata = $2 WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id_clone)
|
||||
.bind(&metadata)
|
||||
.execute(&db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Kunne ikke oppdatere node metadata: {e}");
|
||||
}
|
||||
|
||||
// Synk metadata til STDB
|
||||
let node = sqlx::query_as::<_, (String, String, String, String, String)>(
|
||||
"SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id_clone)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some((kind, t, c, v, m))) = node {
|
||||
let _ = stdb
|
||||
.update_node(&comm_id_clone.to_string(), &kind, &t, &c, &v, &m)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Hent nåværende deltakere fra PG edges (for respons)
|
||||
let participants = sqlx::query_as::<_, (String, String)>(
|
||||
r#"
|
||||
SELECT e.source_id::text, COALESCE(n.title, 'Ukjent')
|
||||
FROM edges e
|
||||
LEFT JOIN nodes n ON n.id = e.source_id
|
||||
WHERE e.target_id = $1
|
||||
AND e.edge_type IN ('owner', 'member_of', 'host_of')
|
||||
"#,
|
||||
)
|
||||
.bind(comm_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved deltakerhenting: {e}");
|
||||
internal_error("Databasefeil")
|
||||
})?
|
||||
.into_iter()
|
||||
.map(|(uid, name)| RoomParticipantInfo {
|
||||
user_id: uid,
|
||||
display_name: name,
|
||||
role: "publisher".to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let livekit_url = std::env::var("LIVEKIT_WS_URL")
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback: bruk domene med wss
|
||||
"wss://sidelinja.org/livekit".to_string()
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %comm_id,
|
||||
user = %user.node_id,
|
||||
room = %room_name,
|
||||
role = %role_str,
|
||||
"Bruker koblet til LiveKit-rom"
|
||||
);
|
||||
|
||||
Ok(Json(JoinCommunicationResponse {
|
||||
livekit_room_name: room_name,
|
||||
livekit_token: token_result.token,
|
||||
livekit_url,
|
||||
identity: token_result.identity,
|
||||
participants,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LeaveCommunicationRequest {
|
||||
pub communication_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LeaveCommunicationResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// POST /intentions/leave_communication
|
||||
///
|
||||
/// Fjerner brukerens sanntidslyd-tilkobling fra en kommunikasjonsnode.
|
||||
/// Oppdaterer STDB med ny deltaker-status. Stenger rommet hvis ingen gjenstår.
|
||||
pub async fn leave_communication(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<LeaveCommunicationRequest>,
|
||||
) -> Result<Json<LeaveCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let comm_id = req.communication_id;
|
||||
let room_name = format!("communication_{comm_id}");
|
||||
let user_id_str = user.node_id.to_string();
|
||||
|
||||
// Fjern deltaker fra STDB
|
||||
if let Err(e) = state.stdb.remove_room_participant(&room_name, &user_id_str).await {
|
||||
tracing::warn!("STDB remove_room_participant feilet: {e}");
|
||||
}
|
||||
|
||||
// Sjekk om rommet er tomt — i så fall steng det
|
||||
// (Vi sjekker PG edges for å se hvem som er registrert som deltaker i STDB)
|
||||
// Forenklet: la rommet ligge, det ryddes ved neste oppstart eller manuelt
|
||||
// Frontend kan kalle close_communication for å eksplisitt stenge.
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %comm_id,
|
||||
user = %user.node_id,
|
||||
room = %room_name,
|
||||
"Bruker forlot LiveKit-rom"
|
||||
);
|
||||
|
||||
Ok(Json(LeaveCommunicationResponse {
|
||||
status: "left".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CloseCommunicationRequest {
|
||||
pub communication_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CloseCommunicationResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// POST /intentions/close_communication
|
||||
///
|
||||
/// Stenger et sanntidsrom. Krever owner/admin-tilgang.
|
||||
/// Fjerner alle deltakere fra STDB, oppdaterer metadata til "ended".
|
||||
pub async fn close_communication(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<CloseCommunicationRequest>,
|
||||
) -> Result<Json<CloseCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let comm_id = req.communication_id;
|
||||
|
||||
// Bare owner/admin kan stenge
|
||||
if !user_can_modify_node(&state.db, user.node_id, comm_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("PG-feil ved tilgangssjekk: {e}");
|
||||
internal_error("Databasefeil")
|
||||
})?
|
||||
{
|
||||
return Err(forbidden("Bare eier kan stenge kommunikasjonsrom"));
|
||||
}
|
||||
|
||||
let room_name = format!("communication_{comm_id}");
|
||||
|
||||
// Steng rommet i STDB
|
||||
if let Err(e) = state.stdb.close_live_room(&room_name).await {
|
||||
tracing::warn!("STDB close_live_room feilet: {e}");
|
||||
}
|
||||
|
||||
// Oppdater metadata i PG
|
||||
let db = state.db.clone();
|
||||
let stdb = state.stdb.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = sqlx::query_scalar::<_, serde_json::Value>(
|
||||
"SELECT metadata FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(mut metadata)) = result {
|
||||
if let Some(obj) = metadata.as_object_mut() {
|
||||
obj.insert("live_status".into(), "ended".into());
|
||||
obj.insert(
|
||||
"ended_at".into(),
|
||||
chrono::Utc::now().to_rfc3339().into(),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = sqlx::query("UPDATE nodes SET metadata = $2 WHERE id = $1")
|
||||
.bind(comm_id)
|
||||
.bind(&metadata)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
// Synk til STDB
|
||||
let node = sqlx::query_as::<_, (String, String, String, String, String)>(
|
||||
"SELECT node_kind, title, content, visibility, metadata::text FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(comm_id)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some((kind, t, c, v, m))) = node {
|
||||
let _ = stdb
|
||||
.update_node(&comm_id.to_string(), &kind, &t, &c, &v, &m)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %comm_id,
|
||||
user = %user.node_id,
|
||||
"Kommunikasjonsrom stengt"
|
||||
);
|
||||
|
||||
Ok(Json(CloseCommunicationResponse {
|
||||
status: "closed".to_string(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
133
maskinrommet/src/livekit.rs
Normal file
133
maskinrommet/src/livekit.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// LiveKit-integrasjon — token-generering for sanntidslyd.
|
||||
//
|
||||
// Genererer JWT access tokens som gir brukere tilgang til LiveKit-rom.
|
||||
// Tokens signeres med LIVEKIT_API_SECRET (HMAC-SHA256) og inneholder
|
||||
// grants som bestemmer hva deltakeren kan gjøre (publisere, lytte, etc.).
|
||||
//
|
||||
// Ref: docs/concepts/møterommet.md, docs/concepts/studioet.md
|
||||
|
||||
use jsonwebtoken::{encode, EncodingKey, Header, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// LiveKit video grant — bestemmer hva en deltaker kan gjøre i et rom.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VideoGrant {
|
||||
room: String,
|
||||
room_join: bool,
|
||||
can_publish: bool,
|
||||
can_subscribe: bool,
|
||||
}
|
||||
|
||||
/// JWT claims for LiveKit access token.
|
||||
#[derive(Serialize)]
|
||||
struct LiveKitClaims {
|
||||
/// API Key (issuer)
|
||||
iss: String,
|
||||
/// Participant identity
|
||||
sub: String,
|
||||
/// Participant name (display)
|
||||
name: String,
|
||||
/// Issued at (unix timestamp)
|
||||
iat: u64,
|
||||
/// Not before (unix timestamp)
|
||||
nbf: u64,
|
||||
/// Expiration (unix timestamp)
|
||||
exp: u64,
|
||||
/// LiveKit video grant
|
||||
video: VideoGrant,
|
||||
/// Metadata (JSON string)
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
metadata: String,
|
||||
}
|
||||
|
||||
/// Rolle i et LiveKit-rom. Bestemmer publiserings-rettigheter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RoomRole {
|
||||
/// Kan publisere og lytte (host, deltaker)
|
||||
Publisher,
|
||||
/// Kan bare lytte (observatør)
|
||||
Subscriber,
|
||||
}
|
||||
|
||||
/// Resultat fra token-generering.
|
||||
pub struct LiveKitToken {
|
||||
pub room_name: String,
|
||||
pub token: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// Generer et LiveKit access token for en deltaker.
|
||||
///
|
||||
/// - `communication_id`: UUID for kommunikasjonsnoden (brukes til rom-navn)
|
||||
/// - `user_id`: Brukerens node_id (brukes som identity)
|
||||
/// - `display_name`: Visningsnavn for deltakeren
|
||||
/// - `role`: Publisher (kan sende lyd) eller Subscriber (bare lytte)
|
||||
/// - `ttl_secs`: Token-levetid i sekunder (typisk 3600 = 1 time)
|
||||
pub fn generate_token(
|
||||
communication_id: Uuid,
|
||||
user_id: Uuid,
|
||||
display_name: &str,
|
||||
role: RoomRole,
|
||||
ttl_secs: u64,
|
||||
) -> Result<LiveKitToken, String> {
|
||||
let api_key = std::env::var("LIVEKIT_API_KEY")
|
||||
.map_err(|_| "LIVEKIT_API_KEY ikke satt".to_string())?;
|
||||
let api_secret = std::env::var("LIVEKIT_API_SECRET")
|
||||
.map_err(|_| "LIVEKIT_API_SECRET ikke satt".to_string())?;
|
||||
|
||||
let room_name = format!("communication_{communication_id}");
|
||||
let identity = user_id.to_string();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let can_publish = role == RoomRole::Publisher;
|
||||
|
||||
let claims = LiveKitClaims {
|
||||
iss: api_key,
|
||||
sub: identity.clone(),
|
||||
name: display_name.to_string(),
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + ttl_secs,
|
||||
video: VideoGrant {
|
||||
room: room_name.clone(),
|
||||
room_join: true,
|
||||
can_publish,
|
||||
can_subscribe: true,
|
||||
},
|
||||
metadata: String::new(),
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
let key = EncodingKey::from_secret(api_secret.as_bytes());
|
||||
|
||||
let token = encode(&header, &claims, &key)
|
||||
.map_err(|e| format!("Kunne ikke generere LiveKit-token: {e}"))?;
|
||||
|
||||
Ok(LiveKitToken {
|
||||
room_name,
|
||||
token,
|
||||
identity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sjekk om LiveKit-serveren er tilgjengelig.
|
||||
pub async fn health_check() -> Result<bool, String> {
|
||||
let url = std::env::var("LIVEKIT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:7880".to_string());
|
||||
|
||||
match reqwest::Client::new()
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("LiveKit utilgjengelig: {e}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ mod auth;
|
|||
pub mod cas;
|
||||
mod intentions;
|
||||
pub mod jobs;
|
||||
pub mod livekit;
|
||||
mod queries;
|
||||
mod serving;
|
||||
mod stdb;
|
||||
|
|
@ -153,6 +154,9 @@ async fn main() {
|
|||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||
.route("/intentions/summarize", post(intentions::summarize))
|
||||
.route("/intentions/generate_tts", post(intentions::generate_tts))
|
||||
.route("/intentions/join_communication", post(intentions::join_communication))
|
||||
.route("/intentions/leave_communication", post(intentions::leave_communication))
|
||||
.route("/intentions/close_communication", post(intentions::close_communication))
|
||||
.route("/query/aliases", get(queries::query_aliases))
|
||||
.route("/query/graph", get(queries::query_graph))
|
||||
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
||||
|
|
|
|||
|
|
@ -281,6 +281,71 @@ impl StdbClient {
|
|||
.await
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Live-rom (LiveKit)
|
||||
// =========================================================================
|
||||
|
||||
pub async fn create_live_room(
|
||||
&self,
|
||||
room_id: &str,
|
||||
communication_id: &str,
|
||||
) -> Result<(), StdbError> {
|
||||
#[derive(Serialize)]
|
||||
struct Args<'a> {
|
||||
room_id: &'a str,
|
||||
communication_id: &'a str,
|
||||
}
|
||||
|
||||
self.call_reducer("create_live_room", &Args { room_id, communication_id })
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn add_room_participant(
|
||||
&self,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
display_name: &str,
|
||||
role: &str,
|
||||
) -> Result<(), StdbError> {
|
||||
#[derive(Serialize)]
|
||||
struct Args<'a> {
|
||||
room_id: &'a str,
|
||||
user_id: &'a str,
|
||||
display_name: &'a str,
|
||||
role: &'a str,
|
||||
}
|
||||
|
||||
self.call_reducer(
|
||||
"add_room_participant",
|
||||
&Args { room_id, user_id, display_name, role },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_room_participant(
|
||||
&self,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), StdbError> {
|
||||
#[derive(Serialize)]
|
||||
struct Args<'a> {
|
||||
room_id: &'a str,
|
||||
user_id: &'a str,
|
||||
}
|
||||
|
||||
self.call_reducer("remove_room_participant", &Args { room_id, user_id })
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn close_live_room(&self, room_id: &str) -> Result<(), StdbError> {
|
||||
#[derive(Serialize)]
|
||||
struct Args<'a> {
|
||||
room_id: &'a str,
|
||||
}
|
||||
|
||||
self.call_reducer("close_live_room", &Args { room_id }).await
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Vedlikehold
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ LITELLM_MASTER_KEY=$(read_env LITELLM_MASTER_KEY)
|
|||
LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880
|
||||
LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY)
|
||||
LIVEKIT_API_SECRET=$(read_env LIVEKIT_API_SECRET)
|
||||
LIVEKIT_WS_URL=$(read_env LIVEKIT_WS_URL)
|
||||
ELEVENLABS_API_KEY=$(read_env ELEVENLABS_API_KEY)
|
||||
ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
|
||||
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
|
||||
|
|
|
|||
|
|
@ -273,6 +273,147 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Live-rom (sanntidslyd via LiveKit)
|
||||
// =============================================================================
|
||||
|
||||
/// Aktive LiveKit-rom knyttet til kommunikasjonsnoder.
|
||||
/// Transient — finnes bare mens rommet er aktivt.
|
||||
#[spacetimedb::table(accessor = live_room, public)]
|
||||
pub struct LiveRoom {
|
||||
#[primary_key]
|
||||
pub room_id: String, // "communication_{uuid}"
|
||||
|
||||
#[index(btree)]
|
||||
pub communication_id: String,
|
||||
pub is_active: bool,
|
||||
pub started_at: Timestamp,
|
||||
pub participant_count: u32,
|
||||
}
|
||||
|
||||
/// Deltakere i aktive LiveKit-rom.
|
||||
#[spacetimedb::table(accessor = room_participant, public)]
|
||||
pub struct RoomParticipant {
|
||||
#[primary_key]
|
||||
pub id: String, // "{room_id}:{user_id}"
|
||||
|
||||
#[index(btree)]
|
||||
pub room_id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub display_name: String,
|
||||
pub role: String, // "publisher" | "subscriber"
|
||||
pub joined_at: Timestamp,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn create_live_room(
|
||||
ctx: &ReducerContext,
|
||||
room_id: String,
|
||||
communication_id: String,
|
||||
) -> Result<(), String> {
|
||||
if room_id.is_empty() {
|
||||
return Err("room_id kan ikke være tom".into());
|
||||
}
|
||||
// Idempotent — hvis rommet allerede finnes, oppdater
|
||||
if let Some(existing) = ctx.db.live_room().room_id().find(&room_id) {
|
||||
ctx.db.live_room().room_id().update(LiveRoom {
|
||||
is_active: true,
|
||||
..existing
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
ctx.db.live_room().insert(LiveRoom {
|
||||
room_id,
|
||||
communication_id,
|
||||
is_active: true,
|
||||
started_at: ctx.timestamp,
|
||||
participant_count: 0,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn add_room_participant(
|
||||
ctx: &ReducerContext,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
display_name: String,
|
||||
role: String,
|
||||
) -> Result<(), String> {
|
||||
let id = format!("{room_id}:{user_id}");
|
||||
|
||||
// Idempotent — oppdater hvis allerede finnes
|
||||
if let Some(existing) = ctx.db.room_participant().id().find(&id) {
|
||||
ctx.db.room_participant().id().update(RoomParticipant {
|
||||
display_name,
|
||||
role,
|
||||
..existing
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.db.room_participant().insert(RoomParticipant {
|
||||
id,
|
||||
room_id: room_id.clone(),
|
||||
user_id,
|
||||
display_name,
|
||||
role,
|
||||
joined_at: ctx.timestamp,
|
||||
});
|
||||
|
||||
// Oppdater deltakertelling
|
||||
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
|
||||
ctx.db.live_room().room_id().update(LiveRoom {
|
||||
participant_count: room.participant_count + 1,
|
||||
..room
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn remove_room_participant(
|
||||
ctx: &ReducerContext,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
) -> Result<(), String> {
|
||||
let id = format!("{room_id}:{user_id}");
|
||||
ctx.db.room_participant().id().delete(&id);
|
||||
|
||||
// Oppdater deltakertelling
|
||||
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
|
||||
let new_count = room.participant_count.saturating_sub(1);
|
||||
ctx.db.live_room().room_id().update(LiveRoom {
|
||||
participant_count: new_count,
|
||||
..room
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn close_live_room(
|
||||
ctx: &ReducerContext,
|
||||
room_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Fjern alle deltakere
|
||||
let participants: Vec<_> = ctx.db.room_participant().room_id().filter(&room_id).collect();
|
||||
for p in participants {
|
||||
ctx.db.room_participant().id().delete(&p.id);
|
||||
}
|
||||
|
||||
// Marker rommet som inaktivt
|
||||
if let Some(room) = ctx.db.live_room().room_id().find(&room_id) {
|
||||
ctx.db.live_room().room_id().update(LiveRoom {
|
||||
is_active: false,
|
||||
participant_count: 0,
|
||||
..room
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Warmup/vedlikehold
|
||||
// =============================================================================
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -124,8 +124,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
## Fase 11: Produksjons-pipeline
|
||||
|
||||
- [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`.
|
||||
- [~] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
|
||||
> Påbegynt: 2026-03-17T23:42
|
||||
- [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
|
||||
- [ ] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil.
|
||||
- [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue