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 }`
|
- Body (JSON): `{ node_id }`
|
||||||
- Respons: `{ deleted: true }`
|
- 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
|
## 6. Instruks for Claude Code
|
||||||
|
|
||||||
- Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx.
|
- Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
use crate::livekit;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Maks filstørrelse for upload: 100 MB.
|
/// Maks filstørrelse for upload: 100 MB.
|
||||||
|
|
@ -2319,3 +2320,392 @@ pub async fn generate_tts(
|
||||||
|
|
||||||
Ok(Json(GenerateTtsResponse { job_id }))
|
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;
|
pub mod cas;
|
||||||
mod intentions;
|
mod intentions;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
|
pub mod livekit;
|
||||||
mod queries;
|
mod queries;
|
||||||
mod serving;
|
mod serving;
|
||||||
mod stdb;
|
mod stdb;
|
||||||
|
|
@ -153,6 +154,9 @@ async fn main() {
|
||||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||||
.route("/intentions/summarize", post(intentions::summarize))
|
.route("/intentions/summarize", post(intentions::summarize))
|
||||||
.route("/intentions/generate_tts", post(intentions::generate_tts))
|
.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/aliases", get(queries::query_aliases))
|
||||||
.route("/query/graph", get(queries::query_graph))
|
.route("/query/graph", get(queries::query_graph))
|
||||||
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,71 @@ impl StdbClient {
|
||||||
.await
|
.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
|
// Vedlikehold
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ LITELLM_MASTER_KEY=$(read_env LITELLM_MASTER_KEY)
|
||||||
LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880
|
LIVEKIT_URL=http://${LIVEKIT_IP:-localhost}:7880
|
||||||
LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY)
|
LIVEKIT_API_KEY=$(read_env LIVEKIT_API_KEY)
|
||||||
LIVEKIT_API_SECRET=$(read_env LIVEKIT_API_SECRET)
|
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_API_KEY=$(read_env ELEVENLABS_API_KEY)
|
||||||
ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
|
ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
|
||||||
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
|
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,147 @@ pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> {
|
||||||
Ok(())
|
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
|
// Warmup/vedlikehold
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -124,8 +124,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
## Fase 11: Produksjons-pipeline
|
## Fase 11: Produksjons-pipeline
|
||||||
|
|
||||||
- [x] 11.1 LiveKit oppsett: Docker-container for WebRTC. Ref: `docs/setup/produksjon.md`.
|
- [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.
|
- [x] 11.2 Sanntidslyd: kommunikasjonsnode med live-status → LiveKit-rom for deltakere.
|
||||||
> Påbegynt: 2026-03-17T23:42
|
|
||||||
- [ ] 11.3 Pruning-logikk: TTL per modalitet, signaler som forlenger levetid, disk-nødventil.
|
- [ ] 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.
|
- [ ] 11.4 Podcast-RSS: samlings-node med publiserings-edges → generert RSS-feed.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue