feat: direct 1:1 calling via relay signaling (Phase 1)
New feature: call someone directly by fingerprint through the relay.
- Client connects with SNI "_signal" for persistent signaling
- RegisterPresence/RegisterPresenceAck for relay registration
- DirectCallOffer routed to target by fingerprint
- DirectCallAnswer with AcceptGeneric/AcceptTrusted/Reject modes
- Relay creates private room (call-{id}), sends CallSetup to both
- Both clients connect to private room for media (existing SFU path)
- Hangup forwarding + cleanup on disconnect
- Desktop CLI: --signal + --call <fingerprint> for testing
- CallRegistry tracks call state (Pending/Ringing/Active/Ended)
- SignalHub manages persistent signaling connections
Tested: Alice calls Bob by fingerprint, relay routes offer, Bob
auto-accepts, both join private room, media flows bidirectionally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
199
crates/wzp-relay/src/call_registry.rs
Normal file
199
crates/wzp-relay/src/call_registry.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! 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<wzp_proto::CallAcceptMode>,
|
||||
/// Private room name (set when accepted).
|
||||
pub room_name: Option<String>,
|
||||
pub created_at: Instant,
|
||||
pub answered_at: Option<Instant>,
|
||||
pub ended_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Registry of active direct calls.
|
||||
pub struct CallRegistry {
|
||||
calls: HashMap<String, DirectCall>,
|
||||
}
|
||||
|
||||
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<DirectCall> {
|
||||
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<DirectCall> {
|
||||
let now = Instant::now();
|
||||
let expired: Vec<String> = 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user