//! Direct call state tracking. //! //! Manages the lifecycle of 1:1 direct calls placed via the `_signal` channel. //! Each call goes through: Pending → Ringing → Active → Ended. use std::collections::HashMap; use std::time::{Duration, Instant}; /// State of a direct call. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DirectCallState { /// Offer sent to callee, waiting for response. Pending, /// Callee acknowledged, ringing. Ringing, /// Call accepted, media room active. Active, /// Call ended (hangup, reject, timeout, or error). Ended, } /// A tracked direct call between two users. pub struct DirectCall { pub call_id: String, pub caller_fingerprint: String, pub callee_fingerprint: String, pub state: DirectCallState, pub accept_mode: Option, /// Private room name (set when accepted). pub room_name: Option, pub created_at: Instant, pub answered_at: Option, pub ended_at: Option, } /// Registry of active direct calls. pub struct CallRegistry { calls: HashMap, } impl CallRegistry { pub fn new() -> Self { Self { calls: HashMap::new(), } } /// Create a new pending call. Returns the call_id. pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall { let call = DirectCall { call_id: call_id.clone(), caller_fingerprint: caller_fp, callee_fingerprint: callee_fp, state: DirectCallState::Pending, accept_mode: None, room_name: None, created_at: Instant::now(), answered_at: None, ended_at: None, }; self.calls.insert(call_id.clone(), call); self.calls.get(&call_id).unwrap() } /// Get a call by ID. pub fn get(&self, call_id: &str) -> Option<&DirectCall> { self.calls.get(call_id) } /// Get a mutable call by ID. pub fn get_mut(&mut self, call_id: &str) -> Option<&mut DirectCall> { self.calls.get_mut(call_id) } /// Transition to Ringing state. pub fn set_ringing(&mut self, call_id: &str) -> bool { if let Some(call) = self.calls.get_mut(call_id) { if call.state == DirectCallState::Pending { call.state = DirectCallState::Ringing; return true; } } false } /// Transition to Active state. pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool { if let Some(call) = self.calls.get_mut(call_id) { if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing { call.state = DirectCallState::Active; call.accept_mode = Some(mode); call.room_name = Some(room); call.answered_at = Some(Instant::now()); return true; } } false } /// End a call. pub fn end_call(&mut self, call_id: &str) -> Option { if let Some(call) = self.calls.get_mut(call_id) { call.state = DirectCallState::Ended; call.ended_at = Some(Instant::now()); } self.calls.remove(call_id) } /// Find active/pending calls involving a fingerprint. pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> { self.calls.values() .filter(|c| { c.state != DirectCallState::Ended && (c.caller_fingerprint == fp || c.callee_fingerprint == fp) }) .collect() } /// Find the peer's fingerprint in a call. pub fn peer_fingerprint(&self, call_id: &str, my_fp: &str) -> Option<&str> { self.calls.get(call_id).map(|c| { if c.caller_fingerprint == my_fp { c.callee_fingerprint.as_str() } else { c.caller_fingerprint.as_str() } }) } /// Remove calls that have been pending longer than the timeout. /// Returns call IDs of expired calls. pub fn expire_stale(&mut self, timeout: Duration) -> Vec { let now = Instant::now(); let expired: Vec = self.calls.iter() .filter(|(_, c)| { c.state == DirectCallState::Pending && now.duration_since(c.created_at) > timeout }) .map(|(id, _)| id.clone()) .collect(); expired.into_iter() .filter_map(|id| self.calls.remove(&id)) .collect() } /// Number of active (non-ended) calls. pub fn active_count(&self) -> usize { self.calls.values() .filter(|c| c.state != DirectCallState::Ended) .count() } } #[cfg(test)] mod tests { use super::*; #[test] fn call_lifecycle() { let mut reg = CallRegistry::new(); reg.create_call("c1".into(), "alice".into(), "bob".into()); assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Pending); assert!(reg.set_ringing("c1")); assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing); assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into())); assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active); assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1")); let ended = reg.end_call("c1").unwrap(); assert_eq!(ended.state, DirectCallState::Ended); assert_eq!(reg.active_count(), 0); } #[test] fn expire_stale_calls() { let mut reg = CallRegistry::new(); reg.create_call("c1".into(), "alice".into(), "bob".into()); // Not expired yet let expired = reg.expire_stale(Duration::from_secs(30)); assert!(expired.is_empty()); // Force expiry with 0 timeout let expired = reg.expire_stale(Duration::from_secs(0)); assert_eq!(expired.len(), 1); assert_eq!(expired[0].call_id, "c1"); } #[test] fn peer_lookup() { let mut reg = CallRegistry::new(); reg.create_call("c1".into(), "alice".into(), "bob".into()); assert_eq!(reg.peer_fingerprint("c1", "alice"), Some("bob")); assert_eq!(reg.peer_fingerprint("c1", "bob"), Some("alice")); } }