//! Legacy `VoiceManager` facade backed by the native gateway transport. use crate::*; use base64::Engine as _; use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; use libremetaverse_types::UUID; use libremetaverse_types::compat::{CancellationToken, Subscription}; use roxmltree::{Document, Node}; use std::collections::HashMap; use std::sync::{ Arc, Condvar, Mutex, Weak, atomic::{AtomicBool, AtomicU64, Ordering}, }; use std::time::Duration; fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mutex .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum ManagerEvent { AccountChannelGetList, AccountLogout, AuxAudioProperties, CaptureDevices, ConnectorCreated, ConnectorShutdown, Login, LoginState, NewSession, ParcelVoiceInfo, ParticipantProperties, ParticipantState, ProvisionAccount, RenderDevices, SessionConnected, SessionCreated, SessionState, SessionTerminated, } #[derive(Clone, Copy)] enum CapabilityKind { Provision, Parcel, } type Erased = Arc; #[derive(Default)] struct ManagerEvents { next_id: AtomicU64, handlers: Arc>>>, } impl ManagerEvents { fn subscribe(&self, key: ManagerEvent, handler: Option) -> Subscription where T: Clone + Send + Sync + 'static, { let Some(handler) = handler else { return Subscription::detached(); }; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let erased: Erased = Arc::new(move |value| { if let Some(invoke) = value.downcast_ref::>() { invoke(&handler); } }); lock(&self.handlers) .entry(key) .or_default() .insert(id, erased); let handlers = Arc::downgrade(&self.handlers); Subscription::new(move || remove(&handlers, key, id)) } fn emit(&self, key: ManagerEvent, invoke: F) where T: Clone + Send + Sync + 'static, F: Fn(&T) + Send + Sync + 'static, { let handlers: Vec<_> = lock(&self.handlers) .get(&key) .into_iter() .flat_map(|entries| entries.values().cloned()) .collect(); let invoke: Box = Box::new(invoke); for handler in handlers { handler(&invoke); } } } fn remove( handlers: &Weak>>>, key: ManagerEvent, id: u64, ) { if let Some(handlers) = handlers.upgrade() { let mut handlers = lock(&handlers); if let Some(event) = handlers.get_mut(&key) { event.remove(&id); } } } #[derive(Default)] struct ManagerState { capture_devices: Vec, render_devices: Vec, channel_map: HashMap, connector_handle: String, account_handle: String, status_code: i32, tuning_sound_file: String, } #[derive(Default)] struct ResponseSignal { completed: Mutex, wake: Condvar, } impl ResponseSignal { fn notify(&self) { *lock(&self.completed) = true; self.wake.notify_all(); } fn wait(&self, timeout: Duration) -> bool { let completed = lock(&self.completed); let (completed, _) = self .wake .wait_timeout_while(completed, timeout, |value| !*value) .unwrap_or_else(std::sync::PoisonError::into_inner); *completed } } struct VoiceManagerInner { client: libremetaverse::GridClient, gateway: VoiceGateway, state: Mutex, events: ManagerEvents, signals: Mutex>>, subscriptions: Mutex>, disposed: AtomicBool, } pub struct VoiceManager { pub blocking_timeout: i32, pub voice_server: String, inner: Arc, } macro_rules! subscription { ($name:ident, $key:ident, $type:ty) => { #[must_use] pub fn $name(&self, handler: Option<$type>) -> Subscription { self.inner.events.subscribe(ManagerEvent::$key, handler) } }; } impl VoiceManager { pub const DAEMON_ARGS: &'static str = " -p tcp -h -c -ll "; pub const DAEMON_LOG_LEVEL: i32 = 1; pub const DAEMON_PORT: i32 = 44_124; pub const REQUEST_TERMINATOR: &'static str = "\n\n\n"; pub const VOICE_DEBUG_SERVER: &'static str = "bhd.vivox.com"; pub const VOICE_MAJOR_VERSION: i32 = 1; pub const VOICE_RELEASE_SERVER: &'static str = "bhr.vivox.com"; pub fn new(client: libremetaverse::GridClient) -> Result { let gateway = VoiceGateway::new(client.clone())?; let inner = Arc::new(VoiceManagerInner { client, gateway, state: Mutex::new(ManagerState::default()), events: ManagerEvents::default(), signals: Mutex::new(HashMap::new()), subscriptions: Mutex::new(Vec::new()), disposed: AtomicBool::new(false), }); let weak = Arc::downgrade(&inner); let subscription = inner.gateway.subscribe_control_line(Arc::new(move |line| { if let Some(inner) = weak.upgrade() { let facade = VoiceManager { blocking_timeout: 30_000, voice_server: VoiceManager::VOICE_RELEASE_SERVER.into(), inner, }; let _ = facade.process_line(&line); } })); lock(&inner.subscriptions).push(subscription); Ok(Self { blocking_timeout: 30_000, voice_server: Self::VOICE_RELEASE_SERVER.into(), inner, }) } subscription!( subscribe_on_account_channel_get_list, AccountChannelGetList, VoiceManagerBasicActionCallback ); subscription!( subscribe_on_account_logout, AccountLogout, VoiceManagerBasicActionCallback ); subscription!( subscribe_on_aux_audio_properties, AuxAudioProperties, VoiceManagerAuxAudioPropertiesCallback ); subscription!( subscribe_on_capture_devices, CaptureDevices, VoiceManagerDevicesCallback ); subscription!( subscribe_on_connector_created, ConnectorCreated, VoiceManagerConnectorCreatedCallback ); subscription!( subscribe_on_connector_initiate_shutdown, ConnectorShutdown, VoiceManagerBasicActionCallback ); subscription!(subscribe_on_login, Login, VoiceManagerLoginCallback); subscription!( subscribe_on_login_state_change, LoginState, VoiceManagerLoginStateChangeCallback ); subscription!( subscribe_on_new_session, NewSession, VoiceManagerNewSessionCallback ); subscription!( subscribe_on_parcel_voice_info, ParcelVoiceInfo, VoiceManagerParcelVoiceInfoCallback ); subscription!( subscribe_on_participant_properties, ParticipantProperties, VoiceManagerParticipantPropertiesCallback ); subscription!( subscribe_on_participant_state_change, ParticipantState, VoiceManagerParticipantStateChangeCallback ); subscription!( subscribe_on_provision_account, ProvisionAccount, VoiceManagerProvisionAccountCallback ); subscription!( subscribe_on_render_devices, RenderDevices, VoiceManagerDevicesCallback ); subscription!( subscribe_on_session_connected, SessionConnected, VoiceManagerBasicActionCallback ); subscription!( subscribe_on_session_created, SessionCreated, VoiceManagerSessionCreatedCallback ); subscription!( subscribe_on_session_state_change, SessionState, VoiceManagerSessionStateChangeCallback ); subscription!( subscribe_on_session_terminated, SessionTerminated, VoiceManagerBasicActionCallback ); pub fn connect_to_daemon_with_method(&self) -> Result { self.connect_to_daemon_with_string_int32("127.0.0.1".into(), Self::DAEMON_PORT) } pub fn connect_to_daemon_with_string_int32( &self, address: String, port: i32, ) -> Result { self.ensure_active()?; self.inner.gateway.connect_to_daemon(address, port) } pub fn is_daemon_running(&self) -> Result { self.ensure_active()?; Ok(self.inner.gateway.daemon_is_running()) } /// Native releases do not bundle or spawn the proprietary daemon. pub fn start_daemon(&self) -> Result { self.ensure_active()?; Ok(false) } pub fn stop_daemon(&self) -> Result<(), Error> { self.inner.gateway.stop_daemon() } pub fn get_channel_map(&self) -> Result, Error> { self.ensure_active()?; Ok(lock(&self.inner.state).channel_map.clone()) } pub fn current_capture_devices(&self) -> Result, Error> { self.ensure_active()?; Ok(lock(&self.inner.state).capture_devices.clone()) } pub fn current_render_devices(&self) -> Result, Error> { self.ensure_active()?; Ok(lock(&self.inner.state).render_devices.clone()) } pub fn voice_account_from_uuid(&self, id: UUID) -> Result { Ok(format!( "x{}", base64::engine::general_purpose::STANDARD .encode(id.get_bytes()?) .replace('+', "-") .replace('/', "_") )) } pub fn uuid_from_voice_account(&self, account_name: String) -> Result { VoiceParticipant::id_from_name(account_name) } pub fn sipuri_from_voice_account(&self, account: String) -> Result { if account.len() > 16 * 1024 || account.chars().any(char::is_control) { return Err(Error::Argument); } Ok(format!("sip:{account}@{}", self.voice_server)) } pub fn request_capture_devices(&self) -> Result { self.inner.gateway.aux_get_capture_devices() } pub fn request_render_devices(&self) -> Result { self.inner.gateway.aux_get_render_devices() } pub fn request_create_connector_with_method(&self) -> Result { self.request_create_connector_with_string(self.voice_server.clone()) } pub fn request_create_connector_with_string(&self, voice_server: String) -> Result { let mut logging = VoiceGatewayVoiceLoggingSettings::new()?; logging.folder = ".".into(); logging.file_name_prefix = "vivox-gateway".into(); let account_server = format!("https://www.{voice_server}/api2/"); self.inner .gateway .connector_create("V2 SDK".into(), account_server, 0, u16::MAX, logging) } pub fn request_login( &self, account_name: String, password: String, conn_handle: String, ) -> Result { self.inner.gateway.account_login( conn_handle, account_name, password, "VerifyAnswer".into(), String::new(), 10, false, ) } pub fn request_set_render_device(&self, device_name: String) -> Result { self.inner.gateway.aux_set_render_device(device_name) } pub fn request_start_tuning_mode(&self, duration: i32) -> Result { self.inner.gateway.aux_capture_audio_start(duration) } pub fn request_stop_tuning_mode(&self) -> Result { self.inner.gateway.aux_capture_audio_stop() } pub fn request_set_speaker_volume(&self, volume: i32) -> Result { if !(0..=100).contains(&volume) { return Err(Error::Argument); } self.inner.gateway.aux_set_speaker_level(volume) } pub fn request_set_capture_volume(&self, volume: i32) -> Result { if !(0..=100).contains(&volume) { return Err(Error::Argument); } self.inner.gateway.aux_set_mic_level(volume) } pub fn request_render_audio_start(&self, file_name: String, loop_: bool) -> Result { lock(&self.inner.state).tuning_sound_file = file_name.clone(); self.inner.gateway.request_with_string_string( "Aux.RenderAudioStart.1".into(), Some(format!( "{}{}", VoiceGateway::make_xml("SoundFilePath".into(), file_name)?, VoiceGateway::make_xml("Loop".into(), if loop_ { "1" } else { "0" }.into())? )), ) } pub fn request_render_audio_stop(&self) -> Result { let file = lock(&self.inner.state).tuning_sound_file.clone(); self.inner.gateway.request_with_string_string( "Aux.RenderAudioStop.1".into(), Some(VoiceGateway::make_xml("SoundFilePath".into(), file)?), ) } pub fn request_provision_account(&self) -> Result { self.request_capability("ProvisionVoiceAccountRequest", CapabilityKind::Provision) } pub fn request_parcel_voice_info(&self) -> Result { self.request_capability("ParcelVoiceInfoRequest", CapabilityKind::Parcel) } pub fn capture_devices(&self) -> Result, Error> { self.blocking_devices(true) } pub fn render_devices(&self) -> Result, Error> { self.blocking_devices(false) } pub fn create_connector(&self, status: &mut i32) -> Result { let request = self.request_create_connector_with_method()?; if request < 0 { *status = 0; return Ok(String::new()); } let signal = self.signal(request); if !signal.wait(self.timeout()) { *status = 0; return Ok(String::new()); } let state = lock(&self.inner.state); *status = state.status_code; Ok(if *status == 0 { state.connector_handle.clone() } else { String::new() }) } pub fn login( &self, account_name: String, password: String, connector_handle: String, status: &mut i32, ) -> Result { let request = self.request_login(account_name, password, connector_handle)?; if request < 0 { *status = 0; return Ok(String::new()); } let signal = self.signal(request); if !signal.wait(self.timeout()) { *status = 0; return Ok(String::new()); } let state = lock(&self.inner.state); *status = state.status_code; Ok(if *status == 0 { state.account_handle.clone() } else { String::new() }) } pub fn dispose(&self) -> Result<(), Error> { if self.inner.disposed.swap(true, Ordering::AcqRel) { return Ok(()); } self.inner.gateway.dispose()?; lock(&self.inner.state).channel_map.clear(); lock(&self.inner.state).capture_devices.clear(); lock(&self.inner.state).render_devices.clear(); lock(&self.inner.signals).clear(); lock(&self.inner.subscriptions).clear(); Ok(()) } fn ensure_active(&self) -> Result<(), Error> { if self.inner.disposed.load(Ordering::Acquire) { Err(Error::InvalidOperation) } else { Ok(()) } } fn request_capability(&self, name: &str, kind: CapabilityKind) -> Result { self.ensure_active()?; let network = self.inner.client.network(); if !network.connected() { return Ok(false); } let Some(simulator) = network.current_sim() else { return Ok(false); }; let Some(uri) = simulator.native_capability_uri(name)? else { return Ok(false); }; let http = self.inner.client.http_caps_client(); let weak = Arc::downgrade(&self.inner); std::thread::Builder::new() .name(format!("vivox-{}-cap", name.to_ascii_lowercase())) .spawn(move || { let Ok(runtime) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() else { return; }; let response = runtime.block_on( http.post_with_uri_osd_format_osd_cancellation_token_i_progress( uri, OSDFormat::Xml, OSD::Map(HashMap::new()), CancellationToken::default(), None, ), ); let Some(inner) = weak.upgrade() else { return; }; let Ok((status, bytes)) = response else { return; }; if !status.is_success_status_code() { return; } let Ok(OSD::Map(map)) = OSDParser::deserialize_with_bytes(bytes) else { return; }; match kind { CapabilityKind::Provision => { let username = map .get("username") .and_then(|value| value.as_string().ok()) .unwrap_or_default(); let password = map .get("password") .and_then(|value| value.as_string().ok()) .unwrap_or_default(); inner .events .emit::( ManagerEvent::ProvisionAccount, move |callback| { let _ = callback.invoke(username.clone(), password.clone()); }, ); } CapabilityKind::Parcel => { let region = map .get("region_name") .and_then(|value| value.as_string().ok()) .unwrap_or_default(); let local_id = map .get("parcel_local_id") .and_then(|value| value.as_integer().ok()) .unwrap_or_default(); let channel = match map.get("voice_credentials") { Some(OSD::Map(credentials)) => credentials .get("channel_uri") .and_then(|value| value.as_string().ok()) .filter(|value| !value.is_empty()), _ => None, }; inner.events.emit::( ManagerEvent::ParcelVoiceInfo, move |callback| { let _ = callback.invoke(region.clone(), local_id, channel.clone()); }, ); } } }) .map_err(|_| Error::InvalidOperation)?; Ok(true) } fn timeout(&self) -> Duration { Duration::from_millis(u64::try_from(self.blocking_timeout.max(0)).unwrap_or_default()) } fn signal(&self, cookie: i32) -> Arc { lock(&self.inner.signals).entry(cookie).or_default().clone() } fn blocking_devices(&self, capture: bool) -> Result, Error> { let request = if capture { self.request_capture_devices()? } else { self.request_render_devices()? }; if request < 0 { return Ok(Vec::new()); } let signal = self.signal(request); if !signal.wait(self.timeout()) { return Ok(Vec::new()); } if capture { self.current_capture_devices() } else { self.current_render_devices() } } #[cfg(test)] fn process_control_line(&self, line: &str) -> Result<(), Error> { self.inner.gateway.process_control_line(line) } fn process_line(&self, line: &str) -> Result<(), Error> { if line.len() > 1024 * 1024 || line.contains(" { state.connector_handle = value(root, "ConnectorHandle"); let handle = state.connector_handle.clone(); drop(state); self.inner .events .emit::( ManagerEvent::ConnectorCreated, move |callback| { let _ = callback.invoke( cookie, status, status_string.clone(), handle.clone(), ); }, ); } "Account.Login.1" => { state.account_handle = value(root, "AccountHandle"); let handle = state.account_handle.clone(); drop(state); self.inner.events.emit::( ManagerEvent::Login, move |callback| { let _ = callback.invoke( cookie, status, status_string.clone(), handle.clone(), ); }, ); } "Aux.GetCaptureDevices.1" | "Aux.GetRenderDevices.1" => { let capture = action.contains("Capture"); let devices = values( root, if capture { "CaptureDevice" } else { "RenderDevice" }, ); let current = nested( root, if capture { "CurrentCaptureDevice" } else { "CurrentRenderDevice" }, ); if capture { state.capture_devices = devices; } else { state.render_devices = devices; } drop(state); let key = if capture { ManagerEvent::CaptureDevices } else { ManagerEvent::RenderDevices }; self.inner.events.emit::( key, move |callback| { let _ = callback.invoke( cookie, status, status_string.clone(), current.clone(), ); }, ); } "Session.Create.1" => { let handle = value(root, "SessionHandle"); drop(state); self.inner .events .emit::( ManagerEvent::SessionCreated, move |callback| { let _ = callback.invoke( cookie, status, status_string.clone(), handle.clone(), ); }, ); } "Account.ChannelGetList.1" => { state.channel_map = channel_map(root); drop(state); self.inner .events .emit::( ManagerEvent::AccountChannelGetList, move |callback| { let _ = callback.invoke(cookie, status, status_string.clone()); }, ); } _ => { drop(state); if let Some(key) = basic_action(action) { self.inner .events .emit::(key, move |callback| { let _ = callback.invoke(cookie, status, status_string.clone()); }); } } } lock(&self.inner.signals) .entry(cookie) .or_default() .clone() .notify(); } else if root.has_tag_name("Event") { self.process_event(root, cookie, status, status_string); } Ok(()) } fn process_event(&self, root: Node<'_, '_>, cookie: i32, status: i32, status_string: String) { match root.attribute("type").unwrap_or_default() { "LoginStateChangeEvent" | "AccountLoginStateChangeEvent" => { let account = value(root, "AccountHandle"); let state = value_i32(root, "State"); self.inner .events .emit::( ManagerEvent::LoginState, move |callback| { let _ = callback.invoke( cookie, account.clone(), status, status_string.clone(), state, ); }, ); } "SessionNewEvent" => { let account = value(root, "AccountHandle"); let session = value(root, "SessionHandle"); let state = value_i32(root, "State"); let name = value_any(root, &["Name", "ChannelName"]); let uri = value_any(root, &["URI", "Uri"]); self.inner.events.emit::( ManagerEvent::NewSession, move |callback| { let _ = callback.invoke( cookie, account.clone(), session.clone(), state, name.clone(), uri.clone(), ); }, ); } "SessionStateChangeEvent" => { let uri = value_any(root, &["URI", "Uri"]); let session = value(root, "SessionHandle"); let state = value_i32(root, "State"); let is_channel = value_bool(root, "IsChannel"); let name = value_any(root, &["Name", "ChannelName"]); self.inner .events .emit::( ManagerEvent::SessionState, move |callback| { let _ = callback.invoke( cookie, uri.clone(), status, status_string.clone(), session.clone(), state, is_channel, name.clone(), ); }, ); } "ParticipantStateChangeEvent" => { let uri = value_any(root, &["ParticipantUri", "URI", "Uri"]); let state = value_i32(root, "State"); let name = value(root, "AccountName"); let display_name = value(root, "DisplayName"); let participant_type = value_i32(root, "ParticipantType"); self.inner .events .emit::( ManagerEvent::ParticipantState, move |callback| { let _ = callback.invoke( cookie, uri.clone(), status, status_string.clone(), state, name.clone(), display_name.clone(), participant_type, ); }, ); } "ParticipantPropertiesEvent" | "ParticipantUpdatedEvent" => { let uri = value_any(root, &["ParticipantUri", "URI", "Uri"]); let locally_muted = value_bool(root, "IsLocallyMuted"); let moderator_muted = value_bool(root, "IsModeratorMuted"); let speaking = value_bool(root, "IsSpeaking"); let volume = value_i32(root, "Volume"); let energy = value_f32(root, "Energy"); self.inner .events .emit::( ManagerEvent::ParticipantProperties, move |callback| { let _ = callback.invoke( cookie, uri.clone(), status, status_string.clone(), locally_muted, moderator_muted, speaking, volume, energy, ); }, ); } "AuxAudioPropertiesEvent" => { let energy = value_f32(root, "MicEnergy"); self.inner .events .emit::( ManagerEvent::AuxAudioProperties, move |callback| { let _ = callback.invoke(cookie, energy); }, ); } _ => {} } } } fn basic_action(action: &str) -> Option { Some(match action { "Session.Connect.1" => ManagerEvent::SessionConnected, "Session.Terminate.1" => ManagerEvent::SessionTerminated, "Account.Logout.1" => ManagerEvent::AccountLogout, "Connector.InitiateShutdown.1" => ManagerEvent::ConnectorShutdown, _ => return None, }) } fn value_any(root: Node<'_, '_>, names: &[&str]) -> String { names .iter() .map(|name| value(root, name)) .find(|value| !value.is_empty()) .unwrap_or_default() } fn value_bool(root: Node<'_, '_>, name: &str) -> bool { matches!( value(root, name).to_ascii_lowercase().as_str(), "1" | "true" ) } fn channel_map(root: Node<'_, '_>) -> HashMap { root.descendants() .filter(|node| node.has_tag_name("Channel")) .filter_map(|node| { let name = value(node, "Name"); let uri = value_any(node, &["URI", "Uri"]); (!name.is_empty() && !uri.is_empty()).then_some((name, uri)) }) .collect() } fn value(root: Node<'_, '_>, name: &str) -> String { root.descendants() .find(|node| node.has_tag_name(name)) .and_then(|node| node.text()) .unwrap_or_default() .to_owned() } fn value_i32(root: Node<'_, '_>, name: &str) -> i32 { value(root, name).parse().unwrap_or_default() } fn value_f32(root: Node<'_, '_>, name: &str) -> f32 { value(root, name).parse().unwrap_or_default() } fn nested(root: Node<'_, '_>, name: &str) -> String { root.descendants() .find(|node| node.has_tag_name(name)) .map(|node| value(node, "Device")) .unwrap_or_default() } fn values(root: Node<'_, '_>, name: &str) -> Vec { root.descendants() .filter(|node| node.has_tag_name(name)) .map(|node| value(node, "Device")) .filter(|value| !value.is_empty()) .collect() } #[cfg(test)] mod tests { use super::*; use std::sync::mpsc; #[test] fn account_and_sip_conversions_round_trip() { let client = libremetaverse::GridClient::new().unwrap(); let manager = VoiceManager::new(client).unwrap(); let id = UUID::new_with_string("1673cfd3-8229-4445-8d92-ec3570e5e587".into()).unwrap(); let account = manager.voice_account_from_uuid(id).unwrap(); assert_eq!( manager.uuid_from_voice_account(account.clone()).unwrap(), id ); assert_eq!( manager .sipuri_from_voice_account(account) .unwrap() .split('@') .count(), 2 ); } #[test] fn response_parser_updates_device_snapshot_and_invokes_callback() { let client = libremetaverse::GridClient::new().unwrap(); let manager = VoiceManager::new(client).unwrap(); let observed = Arc::new(Mutex::new(String::new())); let output = Arc::clone(&observed); let _subscription = manager.subscribe_on_capture_devices(Some( VoiceManagerDevicesCallback::from_handler(move |_, _, _, current| { *lock(&output) = current; Ok(()) }), )); manager.process_control_line("00OKUSBUSB").unwrap(); assert_eq!(manager.current_capture_devices().unwrap(), ["USB"]); assert_eq!(*lock(&observed), "USB"); assert!(manager.signal(7).wait(Duration::ZERO)); } #[test] fn session_and_participant_events_preserve_protocol_payloads() { let manager = VoiceManager::new(libremetaverse::GridClient::new().unwrap()).unwrap(); let (session_sender, session_receiver) = mpsc::channel(); let _session = manager.subscribe_on_session_state_change(Some( VoiceManagerSessionStateChangeCallback::from_handler( move |_, uri, status, _, handle, state, channel, name| { session_sender .send((uri, status, handle, state, channel, name)) .unwrap(); Ok(()) }, ), )); let (participant_sender, participant_receiver) = mpsc::channel(); let _participant = manager.subscribe_on_participant_properties(Some( VoiceManagerParticipantPropertiesCallback::from_handler( move |_, uri, _, _, local, moderator, speaking, volume, energy| { participant_sender .send((uri, local, moderator, speaking, volume, energy)) .unwrap(); Ok(()) }, ), )); manager.process_control_line("0OKsession-1sip:channel@example.test4trueTown square").unwrap(); manager.process_control_line("sip:avatar@example.test01true610.5").unwrap(); assert_eq!( session_receiver.recv().unwrap(), ( "sip:channel@example.test".into(), 0, "session-1".into(), 4, true, "Town square".into() ) ); assert_eq!( participant_receiver.recv().unwrap(), ("sip:avatar@example.test".into(), false, true, true, 61, 0.5) ); } #[test] fn channel_list_response_updates_name_to_uri_map() { let manager = VoiceManager::new(libremetaverse::GridClient::new().unwrap()).unwrap(); manager.process_control_line("0OKTown squaresip:town@example.test").unwrap(); assert_eq!( manager .get_channel_map() .unwrap() .get("Town square") .map(String::as_str), Some("sip:town@example.test") ); } }