//! SIP dialog state machine (RFC 3261 §12). //! //! Tracks local/remote tags, CSeq counters, route set, and remote target. //! Provides methods to build in-dialog requests (BYE, re-INVITE, ACK, CANCEL). //! //! Ported from ts/sip/dialog.ts. use crate::helpers::{generate_branch, generate_tag}; use crate::message::SipMessage; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DialogState { Early, Confirmed, Terminated, } /// SIP dialog state per RFC 3261 §12. #[derive(Debug, Clone)] pub struct SipDialog { pub call_id: String, pub local_tag: String, pub remote_tag: Option, pub local_uri: String, pub remote_uri: String, pub local_cseq: u32, pub remote_cseq: u32, pub route_set: Vec, pub remote_target: String, pub state: DialogState, pub local_host: String, pub local_port: u16, } impl SipDialog { /// Create a dialog from an INVITE we are sending (UAC side). /// The dialog enters Early state; call `process_response()` when responses arrive. pub fn from_uac_invite(invite: &SipMessage, local_host: &str, local_port: u16) -> Self { let from = invite.get_header("From").unwrap_or(""); let to = invite.get_header("To").unwrap_or(""); let local_cseq = invite .get_header("CSeq") .and_then(|c| c.split_whitespace().next()) .and_then(|s| s.parse().ok()) .unwrap_or(1); Self { call_id: invite.call_id().to_string(), local_tag: SipMessage::extract_tag(from) .map(|s| s.to_string()) .unwrap_or_else(generate_tag), remote_tag: None, local_uri: SipMessage::extract_uri(from) .unwrap_or("") .to_string(), remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(), local_cseq, remote_cseq: 0, route_set: Vec::new(), remote_target: invite .request_uri() .or_else(|| SipMessage::extract_uri(to)) .unwrap_or("") .to_string(), state: DialogState::Early, local_host: local_host.to_string(), local_port, } } /// Create a dialog from an INVITE we received (UAS side). pub fn from_uas_invite( invite: &SipMessage, local_tag: &str, local_host: &str, local_port: u16, ) -> Self { let from = invite.get_header("From").unwrap_or(""); let to = invite.get_header("To").unwrap_or(""); let contact = invite.get_header("Contact"); let remote_target = contact .and_then(SipMessage::extract_uri) .or_else(|| SipMessage::extract_uri(from)) .unwrap_or("") .to_string(); Self { call_id: invite.call_id().to_string(), local_tag: local_tag.to_string(), remote_tag: SipMessage::extract_tag(from).map(|s| s.to_string()), local_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(), remote_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(), local_cseq: 0, remote_cseq: 0, route_set: Vec::new(), remote_target, state: DialogState::Early, local_host: local_host.to_string(), local_port, } } /// Update dialog state from a received response. pub fn process_response(&mut self, response: &SipMessage) { let to = response.get_header("To").unwrap_or(""); let tag = SipMessage::extract_tag(to).map(|s| s.to_string()); let code = response.status_code().unwrap_or(0); // Always update remoteTag from 2xx (RFC 3261 §12.1.2). if let Some(ref t) = tag { if code >= 200 && code < 300 { self.remote_tag = Some(t.clone()); } else if self.remote_tag.is_none() { self.remote_tag = Some(t.clone()); } } // Update remote target from Contact. if let Some(contact) = response.get_header("Contact") { if let Some(uri) = SipMessage::extract_uri(contact) { self.remote_target = uri.to_string(); } } // Record-Route → route set (in reverse for UAC). if self.state == DialogState::Early { let rr: Vec = response .headers .iter() .filter(|(n, _)| n.to_ascii_lowercase() == "record-route") .map(|(_, v)| v.clone()) .collect(); if !rr.is_empty() { let mut reversed = rr; reversed.reverse(); self.route_set = reversed; } } if code >= 200 && code < 300 { self.state = DialogState::Confirmed; } else if code >= 300 { self.state = DialogState::Terminated; } } /// Build an in-dialog request (BYE, re-INVITE, INFO, ...). /// Automatically increments the local CSeq. pub fn create_request( &mut self, method: &str, body: Option<&str>, content_type: Option<&str>, extra_headers: Option>, ) -> SipMessage { self.local_cseq += 1; let branch = generate_branch(); let remote_tag_str = self .remote_tag .as_ref() .map(|t| format!(";tag={t}")) .unwrap_or_default(); let mut headers = vec![ ( "Via".to_string(), format!( "SIP/2.0/UDP {}:{};branch={branch};rport", self.local_host, self.local_port ), ), ( "From".to_string(), format!("<{}>;tag={}", self.local_uri, self.local_tag), ), ( "To".to_string(), format!("<{}>{remote_tag_str}", self.remote_uri), ), ("Call-ID".to_string(), self.call_id.clone()), ( "CSeq".to_string(), format!("{} {method}", self.local_cseq), ), ("Max-Forwards".to_string(), "70".to_string()), ]; for route in &self.route_set { headers.push(("Route".to_string(), route.clone())); } headers.push(( "Contact".to_string(), format!("", self.local_host, self.local_port), )); if let Some(extra) = extra_headers { headers.extend(extra); } let body_str = body.unwrap_or(""); if !body_str.is_empty() { if let Some(ct) = content_type { headers.push(("Content-Type".to_string(), ct.to_string())); } } headers.push(("Content-Length".to_string(), body_str.len().to_string())); let ruri = self.resolve_ruri(); SipMessage::new( format!("{method} {ruri} SIP/2.0"), headers, body_str.to_string(), ) } /// Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4). pub fn create_ack(&self) -> SipMessage { let branch = generate_branch(); let remote_tag_str = self .remote_tag .as_ref() .map(|t| format!(";tag={t}")) .unwrap_or_default(); let mut headers = vec![ ( "Via".to_string(), format!( "SIP/2.0/UDP {}:{};branch={branch};rport", self.local_host, self.local_port ), ), ( "From".to_string(), format!("<{}>;tag={}", self.local_uri, self.local_tag), ), ( "To".to_string(), format!("<{}>{remote_tag_str}", self.remote_uri), ), ("Call-ID".to_string(), self.call_id.clone()), ( "CSeq".to_string(), format!("{} ACK", self.local_cseq), ), ("Max-Forwards".to_string(), "70".to_string()), ]; for route in &self.route_set { headers.push(("Route".to_string(), route.clone())); } headers.push(("Content-Length".to_string(), "0".to_string())); let ruri = self.resolve_ruri(); SipMessage::new(format!("ACK {ruri} SIP/2.0"), headers, String::new()) } /// Build a CANCEL for the original INVITE (same branch, CSeq). pub fn create_cancel(&self, original_invite: &SipMessage) -> SipMessage { let via = original_invite.get_header("Via").unwrap_or("").to_string(); let from = original_invite.get_header("From").unwrap_or("").to_string(); let to = original_invite.get_header("To").unwrap_or("").to_string(); let headers = vec![ ("Via".to_string(), via), ("From".to_string(), from), ("To".to_string(), to), ("Call-ID".to_string(), self.call_id.clone()), ( "CSeq".to_string(), format!("{} CANCEL", self.local_cseq), ), ("Max-Forwards".to_string(), "70".to_string()), ("Content-Length".to_string(), "0".to_string()), ]; let ruri = original_invite .request_uri() .unwrap_or(&self.remote_target) .to_string(); SipMessage::new( format!("CANCEL {ruri} SIP/2.0"), headers, String::new(), ) } /// Transition the dialog to terminated state. pub fn terminate(&mut self) { self.state = DialogState::Terminated; } /// Resolve Request-URI from route set or remote target. fn resolve_ruri(&self) -> &str { if !self.route_set.is_empty() { if let Some(top_route) = SipMessage::extract_uri(&self.route_set[0]) { if top_route.contains(";lr") { return &self.remote_target; // loose routing } return top_route; // strict routing } } &self.remote_target } } #[cfg(test)] mod tests { use super::*; use crate::message::RequestOptions; fn make_invite() -> SipMessage { SipMessage::create_request( "INVITE", "sip:callee@host", RequestOptions { via_host: "192.168.1.1".to_string(), via_port: 5070, via_transport: None, via_branch: Some("z9hG4bK-test".to_string()), from_uri: "sip:caller@proxy".to_string(), from_display_name: None, from_tag: Some("from-tag".to_string()), to_uri: "sip:callee@host".to_string(), to_display_name: None, to_tag: None, call_id: Some("test-dialog-call".to_string()), cseq: Some(1), contact: Some("".to_string()), max_forwards: None, body: None, content_type: None, extra_headers: None, }, ) } #[test] fn uac_dialog_lifecycle() { let invite = make_invite(); let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070); assert_eq!(dialog.state, DialogState::Early); assert_eq!(dialog.call_id, "test-dialog-call"); assert_eq!(dialog.local_tag, "from-tag"); assert!(dialog.remote_tag.is_none()); // Simulate 200 OK let response = SipMessage::create_response( 200, "OK", &invite, Some(crate::message::ResponseOptions { to_tag: Some("remote-tag".to_string()), contact: Some("".to_string()), ..Default::default() }), ); dialog.process_response(&response); assert_eq!(dialog.state, DialogState::Confirmed); assert_eq!(dialog.remote_tag.as_deref(), Some("remote-tag")); assert_eq!(dialog.remote_target, "sip:callee@10.0.0.1:5060"); } #[test] fn create_bye() { let invite = make_invite(); let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070); dialog.remote_tag = Some("remote-tag".to_string()); dialog.state = DialogState::Confirmed; let bye = dialog.create_request("BYE", None, None, None); assert_eq!(bye.method(), Some("BYE")); assert_eq!(bye.call_id(), "test-dialog-call"); assert_eq!(dialog.local_cseq, 2); let to = bye.get_header("To").unwrap(); assert!(to.contains("tag=remote-tag")); } #[test] fn create_ack() { let invite = make_invite(); let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070); dialog.remote_tag = Some("remote-tag".to_string()); let ack = dialog.create_ack(); assert_eq!(ack.method(), Some("ACK")); assert!(ack.get_header("CSeq").unwrap().contains("ACK")); } #[test] fn create_cancel() { let invite = make_invite(); let dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070); let cancel = dialog.create_cancel(&invite); assert_eq!(cancel.method(), Some("CANCEL")); assert!(cancel.get_header("CSeq").unwrap().contains("CANCEL")); assert!(cancel.start_line.contains("sip:callee@host")); } }