use crate::errors::Result;
use crate::types::CallState;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Instant;
use uuid::Uuid;
pub use rvoip_sip_core::StatusCode;
pub use crate::errors::SessionError as Error;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(pub String);
impl fmt::Debug for SessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SessionId")
.field("bytes", &self.0.len())
.finish()
}
}
impl SessionId {
pub fn new() -> Self {
Self(format!("sess_{}", Uuid::new_v4()))
}
pub fn from_string(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Default for SessionId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionRole {
UAC,
UAS,
}
pub type Session = CallSession;
#[derive(Clone)]
pub struct PreparedCall {
pub session_id: SessionId,
pub from: String,
pub to: String,
pub sdp_offer: String,
pub local_rtp_port: u16,
}
impl fmt::Debug for PreparedCall {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedCall")
.field("session_id", &self.session_id)
.field("from_bytes", &self.from.len())
.field("to_bytes", &self.to.len())
.field("sdp_offer_bytes", &self.sdp_offer.len())
.field("local_rtp_port", &self.local_rtp_port)
.finish()
}
}
#[derive(Clone)]
pub struct CallSession {
pub id: SessionId,
pub from: String,
pub to: String,
pub state: CallState,
pub started_at: Option<Instant>,
pub sip_call_id: Option<String>,
}
impl fmt::Debug for CallSession {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CallSession")
.field("id", &self.id)
.field("from_bytes", &self.from.len())
.field("to_bytes", &self.to.len())
.field("state", &self.state)
.field("started", &self.started_at.is_some())
.field("sip_call_id_present", &self.sip_call_id.is_some())
.field(
"sip_call_id_bytes",
&self.sip_call_id.as_ref().map_or(0, String::len),
)
.finish()
}
}
impl CallSession {
pub fn id(&self) -> &SessionId {
&self.id
}
pub fn state(&self) -> &CallState {
&self.state
}
pub fn is_active(&self) -> bool {
matches!(self.state, CallState::Active)
}
pub async fn wait_for_answer(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"Use SessionManager::wait_for_answer() method instead".to_string(),
))
}
pub async fn hold(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"Use SessionManager::hold_session() method instead".to_string(),
))
}
pub async fn resume(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"Use SessionManager::resume_session() method instead".to_string(),
))
}
pub async fn transfer(&self, _target: &str) -> Result<()> {
Err(crate::errors::SessionError::Other(
"Use SessionManager::transfer_session() method instead".to_string(),
))
}
pub async fn terminate(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"Use SessionManager::terminate_session() method instead".to_string(),
))
}
pub async fn start_recording(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"CallSession has no recording authority; use a coordinator-owned media API".to_string(),
))
}
pub async fn stop_recording(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"CallSession has no recording authority; use a coordinator-owned media API".to_string(),
))
}
pub async fn play_audio(&self, _file: &str) -> Result<()> {
Err(crate::errors::SessionError::Other(
"CallSession has no media playback authority; use a coordinator-owned media API"
.to_string(),
))
}
pub async fn start_media(&self) -> Result<()> {
Err(crate::errors::SessionError::Other(
"CallSession has no media lifecycle authority; media starts through the session state machine"
.to_string(),
))
}
}
#[derive(Clone)]
pub struct IncomingCall {
pub id: SessionId,
pub from: String,
pub to: String,
pub sdp: Option<String>,
pub headers: std::collections::HashMap<String, String>,
pub received_at: Instant,
pub sip_call_id: Option<String>,
}
impl fmt::Debug for IncomingCall {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("IncomingCall")
.field("id", &self.id)
.field("from_bytes", &self.from.len())
.field("to_bytes", &self.to.len())
.field("sdp_present", &self.sdp.is_some())
.field("sdp_bytes", &self.sdp.as_ref().map_or(0, String::len))
.field("header_count", &self.headers.len())
.field("sip_call_id_present", &self.sip_call_id.is_some())
.field(
"sip_call_id_bytes",
&self.sip_call_id.as_ref().map_or(0, String::len),
)
.finish()
}
}
impl IncomingCall {
#[doc(hidden)]
pub fn new_test(
id: SessionId,
from: String,
to: String,
sdp: Option<String>,
headers: std::collections::HashMap<String, String>,
sip_call_id: Option<String>,
) -> Self {
Self {
id,
from,
to,
sdp,
headers,
received_at: Instant::now(),
sip_call_id,
}
}
pub fn caller(&self) -> &str {
&self.from
}
pub fn called(&self) -> &str {
&self.to
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum CallDecision {
Accept(Option<String>),
Reject(String),
Defer,
Forward(String),
}
impl fmt::Debug for CallDecision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Accept(sdp) => formatter
.debug_struct("Accept")
.field("sdp_present", &sdp.is_some())
.field("sdp_bytes", &sdp.as_ref().map_or(0, String::len))
.finish(),
Self::Reject(reason) => formatter
.debug_struct("Reject")
.field("reason_bytes", &reason.len())
.finish(),
Self::Defer => formatter.write_str("Defer"),
Self::Forward(target) => formatter
.debug_struct("Forward")
.field("target_bytes", &target.len())
.finish(),
}
}
}
impl CallDecision {
pub fn reject_with_code(status_code: StatusCode, reason: Option<String>) -> Self {
CallDecision::Reject(reason.unwrap_or_else(|| status_code.to_string()))
}
pub fn accept(sdp: Option<String>) -> Self {
CallDecision::Accept(sdp)
}
pub fn defer() -> Self {
CallDecision::Defer
}
pub fn forward(destination: impl Into<String>) -> Self {
CallDecision::Forward(destination.into())
}
}
#[derive(Debug, Clone)]
pub struct SessionStats {
pub total_sessions: usize,
pub active_sessions: usize,
pub failed_sessions: usize,
pub average_duration: Option<std::time::Duration>,
}
#[derive(Clone)]
pub struct MediaInfo {
pub local_sdp: Option<String>,
pub remote_sdp: Option<String>,
pub local_rtp_port: Option<u16>,
pub remote_rtp_port: Option<u16>,
pub codec: Option<String>,
}
impl fmt::Debug for MediaInfo {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MediaInfo")
.field("local_sdp_present", &self.local_sdp.is_some())
.field(
"local_sdp_bytes",
&self.local_sdp.as_ref().map_or(0, String::len),
)
.field("remote_sdp_present", &self.remote_sdp.is_some())
.field(
"remote_sdp_bytes",
&self.remote_sdp.as_ref().map_or(0, String::len),
)
.field("local_rtp_port", &self.local_rtp_port)
.field("remote_rtp_port", &self.remote_rtp_port)
.field("codec_present", &self.codec.is_some())
.field("codec_bytes", &self.codec.as_ref().map_or(0, String::len))
.finish()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CallDirection {
Outgoing,
Incoming,
}
#[derive(Clone)]
pub enum TerminationReason {
LocalHangup,
RemoteHangup,
Rejected(String),
Error(String),
Timeout,
}
impl fmt::Debug for TerminationReason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LocalHangup => formatter.write_str("LocalHangup"),
Self::RemoteHangup => formatter.write_str("RemoteHangup"),
Self::Rejected(reason) => formatter
.debug_struct("Rejected")
.field("reason_bytes", &reason.len())
.finish(),
Self::Error(error) => formatter
.debug_struct("Error")
.field("error_bytes", &error.len())
.finish(),
Self::Timeout => formatter.write_str("Timeout"),
}
}
}
impl fmt::Display for TerminationReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TerminationReason::LocalHangup => write!(f, "Local hangup"),
TerminationReason::RemoteHangup => write!(f, "Remote hangup"),
TerminationReason::Rejected(reason) => write!(f, "Rejected: {}", reason),
TerminationReason::Error(error) => write!(f, "Error: {}", error),
TerminationReason::Timeout => write!(f, "Timeout"),
}
}
}
#[derive(Clone)]
pub struct SdpInfo {
pub ip: String,
pub port: u16,
pub codecs: Vec<String>,
}
impl fmt::Debug for SdpInfo {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SdpInfo")
.field("ip_bytes", &self.ip.len())
.field("port", &self.port)
.field("codec_count", &self.codecs.len())
.finish()
}
}
pub fn parse_sdp_connection(sdp: &str) -> Result<SdpInfo> {
let mut ip = None;
let mut port = None;
let mut codecs = Vec::new();
for line in sdp.lines() {
if line.starts_with("c=IN IP4 ") {
ip = line.strip_prefix("c=IN IP4 ").map(|s| s.to_string());
} else if line.starts_with("m=audio ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() > 1 {
port = parts[1].parse().ok();
}
if parts.len() > 3 {
for codec in &parts[3..] {
codecs.push(codec.to_string());
}
}
} else if line.starts_with("a=rtpmap:") {
if let Some(codec_info) = line.strip_prefix("a=rtpmap:") {
let parts: Vec<&str> = codec_info.split_whitespace().collect();
if parts.len() >= 2 {
if let Some(codec_name) = parts[1].split('/').next() {
codecs.push(codec_name.to_string());
}
}
}
}
}
match (ip, port) {
(Some(ip), Some(port)) => Ok(SdpInfo { ip, port, codecs }),
_ => Err(crate::errors::SessionError::MediaIntegration {
reason: "Failed to parse SDP connection information".to_string(),
}),
}
}
pub use rvoip_media_core::types::AudioFrame;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioStreamConfig {
pub sample_rate: u32,
pub channels: u8,
pub codec: String,
pub frame_size_ms: u32,
pub enable_aec: bool,
pub enable_agc: bool,
pub enable_vad: bool,
}
impl Default for AudioStreamConfig {
fn default() -> Self {
Self {
sample_rate: 8000, channels: 1, codec: "PCMU".to_string(), frame_size_ms: 20, enable_aec: true,
enable_agc: true,
enable_vad: true,
}
}
}
impl AudioStreamConfig {
pub fn new(sample_rate: u32, channels: u8, codec: impl Into<String>) -> Self {
Self {
sample_rate,
channels,
codec: codec.into(),
..Default::default()
}
}
pub fn frame_size_samples(&self) -> usize {
(self.sample_rate as usize * self.frame_size_ms as usize) / 1000
}
pub fn frame_size_bytes(&self) -> usize {
self.frame_size_samples() * self.channels as usize * 2 }
pub fn telephony() -> Self {
Self::default()
}
pub fn wideband() -> Self {
Self {
sample_rate: 16000,
codec: "Opus".to_string(),
..Default::default()
}
}
pub fn high_quality() -> Self {
Self {
sample_rate: 48000,
channels: 2,
codec: "Opus".to_string(),
..Default::default()
}
}
}
#[derive(Clone)]
pub struct DialogIdentity {
pub call_id: String,
pub local_tag: Option<String>,
pub remote_tag: Option<String>,
}
impl fmt::Debug for DialogIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DialogIdentity")
.field("call_id_bytes", &self.call_id.len())
.field("local_tag_present", &self.local_tag.is_some())
.field(
"local_tag_bytes",
&self.local_tag.as_ref().map_or(0, String::len),
)
.field("remote_tag_present", &self.remote_tag.is_some())
.field(
"remote_tag_bytes",
&self.remote_tag.as_ref().map_or(0, String::len),
)
.finish()
}
}
impl DialogIdentity {
pub fn to_replaces_value(&self) -> Option<String> {
let (local, remote) = (self.local_tag.as_ref()?, self.remote_tag.as_ref()?);
Some(format!(
"{};to-tag={};from-tag={}",
self.call_id, remote, local
))
}
}
pub struct AudioFrameSubscriber {
session_id: SessionId,
receiver: tokio::sync::mpsc::Receiver<AudioFrame>,
}
impl fmt::Debug for AudioFrameSubscriber {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AudioFrameSubscriber")
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl AudioFrameSubscriber {
pub fn new(session_id: SessionId, receiver: tokio::sync::mpsc::Receiver<AudioFrame>) -> Self {
Self {
session_id,
receiver,
}
}
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
pub async fn recv(&mut self) -> Option<AudioFrame> {
self.receiver.recv().await
}
pub fn try_recv(
&mut self,
) -> std::result::Result<AudioFrame, tokio::sync::mpsc::error::TryRecvError> {
self.receiver.try_recv()
}
pub fn is_connected(&self) -> bool {
!self.receiver.is_closed()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_id_creation() {
let id1 = SessionId::new();
let id2 = SessionId::new();
assert_ne!(id1, id2);
assert!(id1.0.starts_with("sess_"));
}
#[test]
fn test_call_state_display() {
use crate::types::FailureReason;
assert_eq!(CallState::Active.to_string(), "Active");
assert_eq!(
CallState::Failed(FailureReason::Timeout).to_string(),
"Failed(Timeout)"
);
}
#[test]
fn public_call_type_debug_is_payload_free() {
const SECRET: &str = "public-call-type-secret-canary";
let incoming = IncomingCall::new_test(
SessionId(SECRET.to_string()),
SECRET.to_string(),
SECRET.to_string(),
Some(SECRET.to_string()),
std::collections::HashMap::from([("Authorization".to_string(), SECRET.to_string())]),
Some(SECRET.to_string()),
);
let prepared = PreparedCall {
session_id: SessionId(SECRET.to_string()),
from: SECRET.to_string(),
to: SECRET.to_string(),
sdp_offer: SECRET.to_string(),
local_rtp_port: 5004,
};
let dialog = DialogIdentity {
call_id: SECRET.to_string(),
local_tag: Some(SECRET.to_string()),
remote_tag: Some(SECRET.to_string()),
};
for rendered in [
format!("{:?}", SessionId(SECRET.to_string())),
format!("{incoming:?}"),
format!("{prepared:?}"),
format!("{:?}", CallDecision::Accept(Some(SECRET.to_string()))),
format!("{:?}", TerminationReason::Error(SECRET.to_string())),
format!("{dialog:?}"),
] {
assert!(!rendered.contains(SECRET), "debug leaked: {rendered}");
}
}
#[tokio::test]
async fn detached_call_session_never_fabricates_media_success() {
let call = CallSession {
id: SessionId::new(),
from: "sip:alice@example.invalid".to_string(),
to: "sip:bob@example.invalid".to_string(),
state: CallState::Active,
started_at: None,
sip_call_id: None,
};
assert!(call.start_recording().await.is_err());
assert!(call.stop_recording().await.is_err());
assert!(call.play_audio("unused.wav").await.is_err());
assert!(call.start_media().await.is_err());
}
}