use ::session::AuthedUser; use api::http::{HeaderMap, Json, Path, Query, State}; use api::rest::{AppResponseData, NoData}; use livekit_api::access_token::TokenVerifier; use livekit_api::webhooks::WebhookReceiver; use serde::Deserialize; use uuid::Uuid; use crate::errors::ApiError; use crate::services::service::ChatService; use crate::services::voice::{ HandDto, VoiceConfigDto, VoiceHandsDto, VoiceLayoutDto, VoiceLayoutPatchDto, VoiceLayoutSavedDto, VoiceModerationDto, VoiceParticipantDto, VoiceRecordingDto, VoiceSpaceStatesDto, VoiceTokenDto, }; #[utoipa::path( get, path = "/chat/voice/config", tag = "chat", description = "语音功能的搭接参数;未启用时 enabled=false,前端隐藏入口。", responses( (status = 200, description = "voice config", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn voice_config( State(service): State, AuthedUser(_uid): AuthedUser, ) -> Result, ApiError> { Ok(AppResponseData::ok(service.voice_config())) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/token", tag = "chat", description = "为当前用户签发进入该语音频道的 LiveKit 令牌;房间满员返回 409。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "voice token", body = AppResponseData), (status = 400, description = "not a voice channel or livekit disabled", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData), (status = 409, description = "room is full", body = AppResponseData) ) )] pub async fn voice_token( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.voice_token(uid, room_id).await?, )) } #[utoipa::path( get, path = "/chat/rooms/{room_id}/voice/participants", tag = "chat", description = "以 LiveKit 实时状态为准返回通话参与者,并回写 Redis 在场记录。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "participants", body = AppResponseData>), (status = 401, description = "authentication required", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn voice_participants( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result>, ApiError> { Ok(AppResponseData::ok( service.voice_participants(uid, room_id).await?, )) } #[utoipa::path( get, path = "/chat/spaces/{space_name}/voice-states", tag = "chat", description = "侧栏用:该空间下每个语音/舞台频道当前在线人数。", params(("space_name" = String, Path, description = "space name")), responses( (status = 200, description = "voice states", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn voice_space_states( State(service): State, AuthedUser(uid): AuthedUser, Path(space_name): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.voice_space_states(uid, &space_name).await?, )) } #[utoipa::path( get, path = "/chat/rooms/{room_id}/voice/hands", tag = "chat", description = "舞台举手列表;主持人看到全部申请,普通成员只看到自己与已通过者。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "hands", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData) ) )] pub async fn list_hands( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok(service.list_hands(uid, room_id).await?)) } #[utoipa::path( get, path = "/chat/rooms/{room_id}/voice/layout", tag = "chat", description = "读取房间内共享的参与者窗口布局(坐标按画布宽高归一化)。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "shared layout", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData), (status = 403, description = "operation not allowed", body = AppResponseData) ) )] pub async fn get_voice_layout( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.voice_layout(uid, room_id).await?, )) } #[utoipa::path( put, path = "/chat/rooms/{room_id}/voice/layout", tag = "chat", description = "按 key 合并保存参与者窗口布局并广播给房间内所有人:请求只带自己改动的那些格子(收回的列在 removed 里),越界坐标会被夹回画布内。", params(("room_id" = Uuid, Path, description = "room id")), request_body = VoiceLayoutPatchDto, responses( (status = 200, description = "layout saved", body = AppResponseData), (status = 400, description = "request failed validation", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData), (status = 403, description = "operation not allowed", body = AppResponseData) ) )] pub async fn save_voice_layout( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, Json(patch): Json, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.save_voice_layout(uid, room_id, patch).await?, )) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/hands", tag = "chat", description = "在舞台频道举手申请发言。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "hand raised", body = AppResponseData), (status = 400, description = "not a stage channel", body = AppResponseData), (status = 409, description = "already able to speak", body = AppResponseData) ) )] pub async fn raise_hand( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok(service.raise_hand(uid, room_id).await?)) } #[utoipa::path( delete, path = "/chat/rooms/{room_id}/voice/hands/@me", tag = "chat", description = "撤回自己的举手申请。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "hand cancelled", body = AppResponseData), (status = 401, description = "authentication required", body = AppResponseData), (status = 409, description = "an approved speaker must be revoked by a moderator", body = AppResponseData) ) )] pub async fn cancel_hand( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { service.cancel_hand(uid, room_id).await?; Ok(AppResponseData::empty()) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/hands/{user_id}/approve", tag = "chat", description = "主持人批准发言:翻转该成员 can_publish,无需重连。", params( ("room_id" = Uuid, Path, description = "room id"), ("user_id" = i64, Path, description = "target user id") ), responses( (status = 200, description = "approved", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData), (status = 404, description = "hand not found", body = AppResponseData) ) )] pub async fn approve_hand( State(service): State, AuthedUser(uid): AuthedUser, Path((room_id, user_id)): Path<(Uuid, i64)>, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.approve_hand(uid, room_id, user_id).await?, )) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/hands/{user_id}/deny", tag = "chat", description = "主持人拒绝发言申请。", params( ("room_id" = Uuid, Path, description = "room id"), ("user_id" = i64, Path, description = "target user id") ), responses( (status = 200, description = "denied", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn deny_hand( State(service): State, AuthedUser(uid): AuthedUser, Path((room_id, user_id)): Path<(Uuid, i64)>, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.deny_hand(uid, room_id, user_id).await?, )) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/hands/{user_id}/revoke", tag = "chat", description = "主持人收回某人的发言权:can_publish=false 并静音其麦克风。", params( ("room_id" = Uuid, Path, description = "room id"), ("user_id" = i64, Path, description = "target user id") ), responses( (status = 200, description = "revoked", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn revoke_hand( State(service): State, AuthedUser(uid): AuthedUser, Path((room_id, user_id)): Path<(Uuid, i64)>, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.revoke_hand(uid, room_id, user_id).await?, )) } #[derive(Debug, Clone, Deserialize)] pub struct ModerateQuery { #[serde(default)] pub muted: bool, } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/participants/{user_id}/mute", tag = "chat", description = "主持人强制静音或解除静音某人的麦克风(?muted=false 解除)。", params( ("room_id" = Uuid, Path, description = "room id"), ("user_id" = i64, Path, description = "target user id"), ("muted" = Option, Query, description = "false to unmute") ), responses( (status = 200, description = "moderated", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn moderate_mute( State(service): State, AuthedUser(uid): AuthedUser, Path((room_id, user_id)): Path<(Uuid, i64)>, Query(query): Query, ) -> Result, ApiError> { Ok(AppResponseData::ok( service .moderate_mute(uid, room_id, user_id, query.muted) .await?, )) } #[utoipa::path( delete, path = "/chat/rooms/{room_id}/voice/participants/{user_id}", tag = "chat", description = "主持人把某人移出通话;该用户可以再次加入。", params( ("room_id" = Uuid, Path, description = "room id"), ("user_id" = i64, Path, description = "target user id") ), responses( (status = 200, description = "removed", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn kick_participant( State(service): State, AuthedUser(uid): AuthedUser, Path((room_id, user_id)): Path<(Uuid, i64)>, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.kick_participant(uid, room_id, user_id).await?, )) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/recording/start", tag = "chat", description = "录制预留接口;[livekit].recording_enabled=false 时返回 400。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "recording started", body = AppResponseData), (status = 400, description = "recording is not enabled", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn start_recording( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.start_recording(uid, room_id).await?, )) } #[utoipa::path( post, path = "/chat/rooms/{room_id}/voice/recording/stop", tag = "chat", description = "停止录制(预留接口)。", params(("room_id" = Uuid, Path, description = "room id")), responses( (status = 200, description = "recording stopped", body = AppResponseData), (status = 400, description = "recording is not enabled", body = AppResponseData), (status = 403, description = "forbidden", body = AppResponseData) ) )] pub async fn stop_recording( State(service): State, AuthedUser(uid): AuthedUser, Path(room_id): Path, ) -> Result, ApiError> { Ok(AppResponseData::ok( service.stop_recording(uid, room_id).await?, )) } /// LiveKit webhook:无会话,用 api_key/api_secret 验签 Authorization 与 body sha256。 #[utoipa::path( post, path = "/chat/livekit/webhook", tag = "chat", description = "LiveKit 事件回调(签名校验,无会话)。", responses( (status = 200, description = "ok", body = AppResponseData), (status = 401, description = "invalid signature", body = AppResponseData) ) )] pub async fn livekit_webhook( State(service): State, headers: HeaderMap, body: String, ) -> Result, ApiError> { let cfg = service.settings.livekit.clone(); if !cfg.enabled { return Ok(AppResponseData::empty()); } let token = headers .get("authorization") .and_then(|value| value.to_str().ok()) .ok_or_else(ApiError::unauthorized)?; let verifier = TokenVerifier::with_api_key(cfg.api_key.trim(), cfg.api_secret.trim()); let receiver = WebhookReceiver::new(verifier); let event = receiver.receive(&body, token).map_err(|error| { tracing::warn!(%error, "livekit webhook rejected"); ApiError::unauthorized() })?; let Some(room) = event.room.as_ref() else { return Ok(AppResponseData::empty()); }; let Ok(room_id) = Uuid::parse_str(&room.name) else { return Ok(AppResponseData::empty()); }; match event.event.as_str() { "room_started" => { service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id }) .await; } "room_finished" => { if let Err(error) = crate::services::voice::clear_room(&service, room_id).await { tracing::warn!(%error, %room_id, "voice room clear failed"); } service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id }) .await; } "participant_joined" => { if let Some(participant) = event.participant.as_ref() && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity) { let names = crate::services::voice::display_names(&service, &[uid]).await; let (display_name, avatar_url) = names .get(&uid) .cloned() .unwrap_or_else(|| (participant.name.clone(), None)); let member = crate::services::voice::VoiceMember { user_id: uid, display_name, avatar_url, joined_at: participant.joined_at, can_publish: participant .permission .as_ref() .map(|p| p.can_publish) .unwrap_or(true), moderator_muted: false, tracks: Vec::new(), livekit_sid: Some(participant.sid.clone()), }; if let Err(error) = crate::services::voice::write_member(&service, room_id, &member).await { tracing::warn!(%error, %room_id, "voice member write failed"); } } service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id }) .await; } "participant_left" => { if let Some(participant) = event.participant.as_ref() && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity) && let Err(error) = crate::services::voice::remove_member(&service, room_id, uid).await { tracing::warn!(%error, %room_id, "voice member remove failed"); } service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id }) .await; service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoiceHands { room_id }) .await; } "track_published" | "track_unpublished" => { if let Some(participant) = event.participant.as_ref() && let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity) { let tracks = participant .tracks .iter() .map(|t| crate::services::voice::VoiceTrackDto { sid: t.sid.clone(), kind: livekit_protocol::TrackType::try_from(t.r#type) .map(|k| k.as_str_name().to_string()) .unwrap_or_else(|_| "UNKNOWN".to_string()), source: livekit_protocol::TrackSource::try_from(t.source) .map(|s| s.as_str_name().to_string()) .unwrap_or_else(|_| "UNKNOWN".to_string()), muted: t.muted, }) .collect(); if let Some(mut member) = crate::services::voice::read_members(&service, room_id) .await .unwrap_or_default() .into_iter() .find(|m| m.user_id == uid) { member.tracks = tracks; let _ = crate::services::voice::write_member(&service, room_id, &member).await; } } service .audience .realtime .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id }) .await; } _ => {} } Ok(AppResponseData::empty()) }