Skip to main content

netget/server/sip/
mod.rs

1//! SIP server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::{Arc, LazyLock};
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10
11use crate::llm::action_helper::call_llm;
12use crate::llm::ollama_client::OllamaClient;
13use crate::logging::emit::Log;
14use crate::protocol::Event;
15use crate::server::SipProtocol;
16use crate::state::app_state::AppState;
17use actions::{
18    SIP_ACK_EVENT, SIP_BYE_EVENT, SIP_CANCEL_EVENT, SIP_INVITE_EVENT, SIP_OPTIONS_EVENT,
19    SIP_REGISTER_EVENT,
20};
21
22/// SIP server that handles VoIP signaling
23pub struct SipServer;
24
25impl SipServer {
26    /// Spawn SIP server with integrated LLM actions
27    pub async fn spawn_with_llm_actions(
28        listen_addr: SocketAddr,
29        llm_client: OllamaClient,
30        app_state: Arc<AppState>,
31        status_tx: mpsc::UnboundedSender<String>,
32        server_id: crate::state::ServerId,
33    ) -> Result<SocketAddr> {
34        let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
35        let local_addr = socket.local_addr()?;
36        Log::new(Some(&status_tx)).info(format!("SIP server listening on {}", local_addr));
37
38        let protocol = Arc::new(SipProtocol::new());
39
40        let task_registrar = app_state.clone();
41        let accept_handle = tokio::spawn(async move {
42            let mut buffer = vec![0u8; 65535]; // Max UDP packet size
43            let log = Log::new(Some(&status_tx));
44
45            loop {
46                match socket.recv_from(&mut buffer).await {
47                    Ok((n, peer_addr)) => {
48                        let data = buffer[..n].to_vec();
49                        let connection_id =
50                            ConnectionId::new(app_state.get_next_unified_id().await);
51
52                        // Add connection to ServerInstance
53                        use crate::state::server::{
54                            ConnectionState as ServerConnectionState, ConnectionStatus,
55                            ProtocolConnectionInfo,
56                        };
57                        let now = crate::utils::clock::Instant::now();
58                        let conn_state = ServerConnectionState {
59                            id: connection_id,
60                            remote_addr: peer_addr,
61                            local_addr,
62                            bytes_sent: 0,
63                            bytes_received: n as u64,
64                            packets_sent: 0,
65                            packets_received: 1,
66                            last_activity: now,
67                            status: ConnectionStatus::Active,
68                            status_changed_at: now,
69                            protocol_info: ProtocolConnectionInfo::empty(),
70                        };
71                        app_state
72                            .add_connection_to_server(server_id, conn_state)
73                            .await;
74                        let _ = status_tx.send("__UPDATE_UI__".to_string());
75
76                        // Summary + full payload FileOnly: the sip_* event templates
77                        // render the equivalent line to the TUI.
78                        log.debug(format!("SIP received {} bytes from {}", n, peer_addr));
79                        if let Ok(text) = String::from_utf8(data.clone()) {
80                            let preview = crate::utils::truncate_for_log(&text, 200);
81                            log.trace(format!("SIP message: {}", preview));
82                        }
83
84                        let llm_clone = llm_client.clone();
85                        let state_clone = app_state.clone();
86                        let status_clone = status_tx.clone();
87                        let socket_clone = socket.clone();
88                        let protocol_clone = protocol.clone();
89
90                        // Tracked, not detached: stop_server must abort this task too.
91                        let task_owner = app_state.clone();
92                        task_owner
93                            .spawn_server_task(server_id, async move {
94                                let log = Log::new(Some(&status_clone));
95                                // Parse SIP message
96                                let sip_message = match Self::parse_sip_message(&data) {
97                                    Ok(msg) => msg,
98                                    Err(e) => {
99                                        log.warn(format!(
100                                            "SIP failed to parse message from {}: {}",
101                                            peer_addr, e
102                                        ));
103                                        return;
104                                    }
105                                };
106
107                                // Request summary FileOnly: the sip_* event template renders
108                                // the equivalent line to the TUI.
109                                log.debug(format!(
110                                    "SIP {} request from {} (Call-ID: {})",
111                                    sip_message.method, peer_addr, sip_message.call_id
112                                ));
113
114                                // Create event based on SIP method
115                                let event = Self::create_event(
116                                    &sip_message,
117                                    peer_addr,
118                                    local_addr,
119                                    connection_id,
120                                );
121
122                                // Call LLM with SIP event
123                                match call_llm(
124                                    &llm_clone,
125                                    &state_clone,
126                                    server_id,
127                                    Some(connection_id),
128                                    &event,
129                                    protocol_clone.as_ref(),
130                                )
131                                .await
132                                {
133                                    Ok(execution_result) => {
134                                        // ACK takes no response at all (RFC 3261 §17), so the
135                                        // whole reply path is skipped for it - including the
136                                        // no-answer refusal below. Answering an ACK is a protocol
137                                        // violation whatever the model said.
138                                        if sip_message.method == "ACK" {
139                                            log.debug(format!(
140                                            "SIP ACK from {} (Call-ID {}) decision=model_answer; \
141                                             ACK takes no response",
142                                            peer_addr, sip_message.call_id
143                                        ));
144                                            return;
145                                        }
146
147                                        // Pick the action that is actually a SIP response. The model
148                                        // may legitimately return a common action (set_memory,
149                                        // schedule_task, ...) alongside - or ahead of - its reply, and
150                                        // taking `raw_actions.first()` blindly turned that into a
151                                        // fabricated status line while the real answer was ignored.
152                                        let response_action = execution_result
153                                            .raw_actions
154                                            .iter()
155                                            .find(|a| is_sip_response_action(a));
156
157                                        let Some(action) = response_action else {
158                                            // The model was reachable and produced no SIP response.
159                                            // That is not an accept: on REGISTER or INVITE, staying
160                                            // silent leaves the UAC retransmitting for 32 seconds and
161                                            // tells the operator nothing, so refuse explicitly and
162                                            // keep the decision distinguishable from a backend
163                                            // failure (radius' decision= shape).
164                                            log.warn(format!(
165                                            "SIP {} from {} (Call-ID {}) decision=model_silent; \
166                                             refusing with 500 (the model returned no SIP \
167                                             response action)",
168                                            sip_message.method, peer_addr, sip_message.call_id
169                                        ));
170                                            let refusal = Self::build_sip_response(
171                                                &sip_message,
172                                                &serde_json::json!({
173                                                    "status_code": 500,
174                                                    "reason_phrase": "Server Internal Error"
175                                                }),
176                                            );
177                                            if let Err(e) =
178                                                socket_clone.send_to(&refusal, peer_addr).await
179                                            {
180                                                log.error(format!(
181                                                    "SIP failed to send 500 to {}: {}",
182                                                    peer_addr, e
183                                                ));
184                                            }
185                                            return;
186                                        };
187
188                                        // An explicit non-2xx is the model refusing, which must stay
189                                        // separable in the log from the silence above and from a
190                                        // backend outage below.
191                                        let decision = match action
192                                            .get("status_code")
193                                            .and_then(|v| v.as_u64())
194                                        {
195                                            Some(code) if !(200..300).contains(&code) => {
196                                                "model_reject"
197                                            }
198                                            Some(_) => "model_answer",
199                                            // build_sip_response answers 500 for this; naming it
200                                            // here keeps "the model forgot a field" apart from
201                                            // "the model chose 500".
202                                            None => "fail_closed_malformed_action",
203                                        };
204                                        log.debug(format!(
205                                            "SIP {} from {} (Call-ID {}) decision={}",
206                                            sip_message.method,
207                                            peer_addr,
208                                            sip_message.call_id,
209                                            decision
210                                        ));
211
212                                        // Build SIP response from action JSON
213                                        let response =
214                                            Self::build_sip_response(&sip_message, action);
215
216                                        // Send SIP response
217                                        match socket_clone.send_to(&response, peer_addr).await {
218                                            Ok(sent) => {
219                                                // Summary FileOnly: the send action template
220                                                // already reports the send to the TUI.
221                                                log.debug(format!(
222                                                    "SIP sent {} byte response to {}",
223                                                    sent, peer_addr
224                                                ));
225                                            }
226                                            Err(e) => {
227                                                log.error(format!(
228                                                    "SIP failed to send response: {}",
229                                                    e
230                                                ));
231                                            }
232                                        }
233
234                                        // Media flow: SIP is signaling only, but when built with
235                                        // the `rtp` feature and the accept action carries an
236                                        // `rtp_audio` description, we honour the SDP we just
237                                        // negotiated by streaming real RTP to the caller's
238                                        // advertised media address. This is what makes a SIP INVITE
239                                        // result in RTP actually arriving, rather than a 200 OK with
240                                        // an SDP that points at nothing.
241                                        {
242                                            // No `unwrap_or(200)`: a missing status_code was answered
243                                            // 500 above, and treating it as 200 here would stream
244                                            // media for a call that was never accepted.
245                                            let status_code = action
246                                                .get("status_code")
247                                                .and_then(|v| v.as_u64())
248                                                .unwrap_or(0);
249                                            if sip_message.method == "INVITE" && status_code == 200
250                                            {
251                                                if let Some(rtp_audio) = action.get("rtp_audio") {
252                                                    Self::stream_invite_media(
253                                                        sip_message.body.as_deref(),
254                                                        peer_addr,
255                                                        rtp_audio.clone(),
256                                                        status_clone.clone(),
257                                                        state_clone.clone(),
258                                                        server_id,
259                                                    )
260                                                    .await;
261                                                }
262                                            }
263                                        }
264                                    }
265                                    Err(e) => {
266                                        // Non-fatal: a 503 (wire fallback) is built and sent
267                                        // below for everything except ACK, so this is WARN.
268                                        log.warn(format!(
269                                            "SIP LLM error for {} from {} (Call-ID {}): {}",
270                                            sip_message.method, peer_addr, sip_message.call_id, e
271                                        ));
272
273                                        // ACK is the one method that must never be answered
274                                        // (RFC 3261 §17: ACK is not a transaction that takes a
275                                        // response), so silence is correct there and only there.
276                                        if sip_message.method == "ACK" {
277                                            log.debug("SIP ACK needs no response; nothing sent");
278                                            return;
279                                        }
280
281                                        // Everything else gets a 503 Service Unavailable built
282                                        // from the request, so the Via/From/To/Call-ID/CSeq match
283                                        // and the client's transaction actually completes. Left
284                                        // silent, the UAC retransmits on timers A/E and only gives
285                                        // up after Timer B/F (32s).
286                                        if crate::llm::is_overload_error(&e) {
287                                            log.warn(format!(
288                                            "SIP 503 to {} (Call-ID {}): LLM capacity exhausted",
289                                            peer_addr, sip_message.call_id
290                                        ));
291                                        }
292                                        let response = Self::build_sip_response(
293                                            &sip_message,
294                                            &serde_json::json!({
295                                                "status_code": 503,
296                                                "reason_phrase": "Service Unavailable",
297                                                "retry_after": 5
298                                            }),
299                                        );
300                                        match socket_clone.send_to(&response, peer_addr).await {
301                                            Ok(sent) => {
302                                                log.debug(format!(
303                                                    "SIP 503 Service Unavailable to {} ({} bytes)",
304                                                    peer_addr, sent
305                                                ));
306                                            }
307                                            Err(send_err) => {
308                                                log.error(format!(
309                                                    "SIP failed to send 503 to {}: {}",
310                                                    peer_addr, send_err
311                                                ));
312                                            }
313                                        }
314                                    }
315                                }
316                            })
317                            .await;
318                    }
319                    Err(e) => {
320                        log.error(format!("SIP recv error: {}", e));
321                    }
322                }
323            }
324        });
325
326        // Register the accept loop so stop_server can abort it and release the port.
327        task_registrar
328            .register_server_task(server_id, accept_handle)
329            .await;
330
331        Ok(local_addr)
332    }
333
334    /// Parse SIP message from bytes
335    fn parse_sip_message(data: &[u8]) -> Result<SipMessage> {
336        let text = String::from_utf8(data.to_vec())?;
337        let lines: Vec<&str> = text.lines().collect();
338
339        if lines.is_empty() {
340            anyhow::bail!("Empty SIP message");
341        }
342
343        // Parse request line (e.g., "REGISTER sip:example.com SIP/2.0")
344        let request_line: Vec<&str> = lines[0].split_whitespace().collect();
345        if request_line.len() < 3 {
346            anyhow::bail!("Invalid SIP request line");
347        }
348
349        let method = request_line[0].to_string();
350        let request_uri = request_line[1].to_string();
351
352        // Parse headers
353        let mut call_id = String::new();
354        let mut from = String::new();
355        let mut to = String::new();
356        let mut via = Vec::new();
357        let mut cseq = String::new();
358        let mut contact = None;
359        let mut expires = None;
360        let mut content_type = None;
361        let mut content_length = 0;
362
363        let mut i = 1;
364        while i < lines.len() {
365            let line = lines[i];
366            if line.is_empty() {
367                // End of headers
368                i += 1;
369                break;
370            }
371
372            if let Some(colon_pos) = line.find(':') {
373                let (header_name, header_value) = line.split_at(colon_pos);
374                let header_value = header_value[1..].trim();
375
376                match header_name.to_lowercase().as_str() {
377                    "call-id" => call_id = header_value.to_string(),
378                    "from" | "f" => from = header_value.to_string(),
379                    "to" | "t" => to = header_value.to_string(),
380                    "via" | "v" => via.push(header_value.to_string()),
381                    "cseq" => cseq = header_value.to_string(),
382                    "contact" | "m" => contact = Some(header_value.to_string()),
383                    "expires" => expires = header_value.parse().ok(),
384                    "content-type" | "c" => content_type = Some(header_value.to_string()),
385                    "content-length" | "l" => content_length = header_value.parse().unwrap_or(0),
386                    _ => {}
387                }
388            }
389
390            i += 1;
391        }
392
393        // Parse body (SDP)
394        let mut body = String::new();
395        if content_length > 0 && i < lines.len() {
396            body = lines[i..].join("\r\n");
397        }
398
399        Ok(SipMessage {
400            method,
401            request_uri,
402            call_id,
403            from,
404            to,
405            via,
406            cseq,
407            contact,
408            expires,
409            content_type,
410            body: if body.is_empty() { None } else { Some(body) },
411        })
412    }
413
414    /// Create event from SIP message
415    fn create_event(
416        sip_message: &SipMessage,
417        peer_addr: SocketAddr,
418        local_addr: SocketAddr,
419        connection_id: ConnectionId,
420    ) -> Event {
421        let base_data = serde_json::json!({
422            "peer_addr": peer_addr.to_string(),
423            "local_addr": local_addr.to_string(),
424            "connection_id": connection_id.to_string(),
425            "call_id": sip_message.call_id,
426            "from": sip_message.from,
427            "to": sip_message.to,
428            "cseq": sip_message.cseq,
429        });
430
431        match sip_message.method.as_str() {
432            "REGISTER" => {
433                let mut data = base_data;
434                if let Some(contact) = &sip_message.contact {
435                    data["contact"] = serde_json::json!(contact);
436                }
437                if let Some(expires) = sip_message.expires {
438                    data["expires"] = serde_json::json!(expires);
439                }
440                Event {
441                    event_type: &SIP_REGISTER_EVENT,
442                    data,
443                }
444            }
445            "INVITE" => {
446                let mut data = base_data;
447                if let Some(body) = &sip_message.body {
448                    data["sdp"] = serde_json::json!(body);
449                }
450                Event {
451                    event_type: &SIP_INVITE_EVENT,
452                    data,
453                }
454            }
455            "BYE" => Event {
456                event_type: &SIP_BYE_EVENT,
457                data: base_data,
458            },
459            "ACK" => Event {
460                event_type: &SIP_ACK_EVENT,
461                data: base_data,
462            },
463            "OPTIONS" => {
464                let mut data = base_data;
465                data["answer_with"] = serde_json::json!(actions::OPTIONS_ANSWER_WITH);
466                Event {
467                    event_type: &SIP_OPTIONS_EVENT,
468                    data,
469                }
470            }
471            "CANCEL" => Event {
472                event_type: &SIP_CANCEL_EVENT,
473                data: base_data,
474            },
475            _ => {
476                // Unknown method, treat as OPTIONS event
477                Event {
478                    event_type: &SIP_OPTIONS_EVENT,
479                    data: base_data,
480                }
481            }
482        }
483    }
484
485    /// Build SIP response from action JSON
486    fn build_sip_response(request: &SipMessage, response_action: &serde_json::Value) -> Vec<u8> {
487        // A non-object action used to `.expect(...)` here. That is a panic reachable from the
488        // wire - the model's own output, inside a `tokio::spawn` that swallows the panic - so
489        // the peer got silence, the log got nothing, and the server went on reporting Running.
490        // An empty object routes into the missing-status_code branch below, which is the same
491        // fail-closed 500 a malformed action deserves.
492        static EMPTY: LazyLock<serde_json::Map<String, serde_json::Value>> =
493            LazyLock::new(serde_json::Map::new);
494        let response_data = match response_action.as_object() {
495            Some(obj) => obj,
496            None => {
497                tracing::warn!(
498                    "SIP action was not a JSON object ({}); answering 500",
499                    crate::utils::truncate_for_log(&response_action.to_string(), 120)
500                );
501                &EMPTY
502            }
503        };
504        // A missing `status_code` is a malformed action, not a decision, so it must not
505        // become 200. On REGISTER a defaulted 200 is an accepted registration and on INVITE
506        // an accepted call - granted because a field was forgotten. SIP has no default
507        // status, so the honest answer is the server's own 500; the model's actual choices
508        // (401, 403, 486) are unaffected because they name the field.
509        let (status_code, default_reason) =
510            match response_data.get("status_code").and_then(|v| v.as_u64()) {
511                Some(code) => (code as u16, actions::default_reason_phrase(code as u16)),
512                None => {
513                    tracing::warn!(
514                        "SIP action carried no status_code; answering 500 rather than defaulting \
515                     to 200, which would accept the request"
516                    );
517                    (500u16, "Server Internal Error")
518                }
519            };
520
521        let reason_phrase = response_data
522            .get("reason_phrase")
523            .and_then(|v| v.as_str())
524            .unwrap_or(default_reason);
525
526        // Build status line
527        let mut response = format!("SIP/2.0 {} {}\r\n", status_code, reason_phrase);
528
529        // Add Via headers (copy from request)
530        for via in &request.via {
531            response.push_str(&format!("Via: {}\r\n", via));
532        }
533
534        // Add From header (copy from request)
535        response.push_str(&format!("From: {}\r\n", request.from));
536
537        // Add To header (copy from request, add tag if not present)
538        let to_header = if request.to.contains(";tag=") {
539            request.to.clone()
540        } else {
541            // Generate a tag for the response
542            format!("{};tag={}", request.to, Self::generate_tag())
543        };
544        response.push_str(&format!("To: {}\r\n", to_header));
545
546        // Add Call-ID header (copy from request)
547        response.push_str(&format!("Call-ID: {}\r\n", request.call_id));
548
549        // Add CSeq header (copy from request)
550        response.push_str(&format!("CSeq: {}\r\n", request.cseq));
551
552        // Add Contact header for successful REGISTER
553        if request.method == "REGISTER" && status_code == 200 {
554            if let Some(contact) = &request.contact {
555                response.push_str(&format!("Contact: {}\r\n", contact));
556            }
557        }
558
559        // Add Expires header for REGISTER responses
560        if request.method == "REGISTER" {
561            let expires = response_data
562                .get("expires")
563                .and_then(|v| v.as_u64())
564                .unwrap_or(3600);
565            response.push_str(&format!("Expires: {}\r\n", expires));
566        }
567
568        // Add Retry-After when the action asks for one. RFC 3261 §20.33: a 503 SHOULD
569        // carry it, so the caller retries after a bounded wait instead of failing over
570        // to another proxy or giving up on the URI entirely.
571        if let Some(retry_after) = response_data.get("retry_after").and_then(|v| v.as_u64()) {
572            response.push_str(&format!("Retry-After: {}\r\n", retry_after));
573        }
574
575        // Add Allow header for OPTIONS responses
576        if request.method == "OPTIONS" {
577            if let Some(allow_methods) = response_data.get("allow_methods") {
578                if let Some(methods) = allow_methods.as_array() {
579                    let methods_str: Vec<String> = methods
580                        .iter()
581                        .filter_map(|v| v.as_str())
582                        .map(String::from)
583                        .collect();
584                    response.push_str(&format!("Allow: {}\r\n", methods_str.join(", ")));
585                }
586            }
587        }
588
589        // Add body (SDP) for successful INVITE responses
590        let body = if request.method == "INVITE" && status_code == 200 {
591            response_data
592                .get("sdp")
593                .and_then(|v| v.as_str())
594                .map(String::from)
595        } else {
596            None
597        };
598
599        // Add Content-Length and body
600        if let Some(body_text) = &body {
601            response.push_str("Content-Type: application/sdp\r\n");
602            response.push_str(&format!("Content-Length: {}\r\n", body_text.len()));
603            response.push_str("\r\n");
604            response.push_str(body_text);
605        } else {
606            response.push_str("Content-Length: 0\r\n");
607            response.push_str("\r\n");
608        }
609
610        response.into_bytes()
611    }
612
613    /// Generate a random tag for SIP responses
614    fn generate_tag() -> String {
615        use rand::Rng;
616        let tag: u32 = rand::thread_rng().gen();
617        format!("{:x}", tag)
618    }
619
620    /// Stream RTP audio to the media address the caller advertised in its INVITE SDP.
621    ///
622    /// Only compiled with the `rtp` feature. The `rtp_audio` value is the same structured media
623    /// description the RTP protocol understands (`content`, `tone_hz`, `payload_type`,
624    /// `duration_ms`) — the model describes what the call carries, and the shared media engine
625    /// (`crate::server::rtp::media`) owns the samples and RTP framing. A fresh ephemeral UDP
626    /// socket is used because RTP must not share the SIP signaling port.
627    async fn stream_invite_media(
628        caller_sdp: Option<&str>,
629        caller_addr: SocketAddr,
630        rtp_audio: serde_json::Value,
631        status_tx: mpsc::UnboundedSender<String>,
632        app_state: Arc<AppState>,
633        server_id: crate::state::ServerId,
634    ) {
635        use crate::server::rtp::media::{self, AudioCodec, RtpPacketizer};
636
637        let Some((ip, port)) = caller_sdp.and_then(parse_sdp_audio_target) else {
638            Log::new(Some(&status_tx)).warn(
639                "SIP INVITE had rtp_audio but no parseable m=audio target in the caller's SDP",
640            );
641            return;
642        };
643
644        // Refuse to stream anywhere but the source of the INVITE. SIP runs on UDP, so the
645        // source address is trivially spoofable and the SDP body names its own destination: an
646        // attacker who sends one 400-byte INVITE claiming `c=IN IP4 <victim>` would otherwise
647        // have NetGet blast seconds of G.711 at a third party it never heard from. Sending only
648        // to the address that actually spoke to us makes the media path unamplifiable, and the
649        // legitimate case is unaffected because a real UAC advertises its own address.
650        if ip != caller_addr.ip() {
651            Log::new(Some(&status_tx)).warn(format!(
652                "SIP INVITE decision=refused_media_redirect: SDP asks for media at {} but the \
653                 INVITE came from {}; refusing to stream RTP to a third party",
654                ip,
655                caller_addr.ip()
656            ));
657            return;
658        }
659        let target = std::net::SocketAddr::new(ip, port);
660
661        let codec = rtp_audio
662            .get("payload_type")
663            .and_then(|v| v.as_str())
664            .map(AudioCodec::parse)
665            .unwrap_or(Ok(AudioCodec::Pcmu))
666            .unwrap_or(AudioCodec::Pcmu);
667        let content = media::parse_audio_content(&rtp_audio)
668            .unwrap_or(media::AudioContent::Tone { hz: 440.0 });
669        let duration_ms = rtp_audio
670            .get("duration_ms")
671            .and_then(|v| v.as_u64())
672            .unwrap_or(2000);
673
674        // Tracked, not detached: stop_server must abort this task too.
675        let task_owner = app_state.clone();
676        task_owner
677            .spawn_server_task(server_id, async move {
678                let log = Log::new(Some(&status_tx));
679                let socket = match tokio::net::UdpSocket::bind(std::net::SocketAddr::from((
680                    [0u8, 0, 0, 0],
681                    0,
682                )))
683                .await
684                {
685                    Ok(s) => s,
686                    Err(e) => {
687                        log.error(format!("SIP RTP socket bind failed: {}", e));
688                        return;
689                    }
690                };
691                let payload = match media::synthesize(codec, &content, duration_ms) {
692                    Ok(p) => p,
693                    Err(e) => {
694                        log.warn(format!("SIP RTP synthesis: {}", e));
695                        return;
696                    }
697                };
698                let mut packetizer =
699                    RtpPacketizer::new(rand::random(), codec.payload_type(), None, None);
700                let packets = packetizer.packetize(&payload, media::G711_SAMPLES_PER_FRAME);
701                let mut sent = 0u64;
702                for pkt in &packets {
703                    if socket.send_to(pkt, target).await.is_err() {
704                        break;
705                    }
706                    sent += 1;
707                    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
708                }
709                log.info(format!(
710                    "SIP call media: {} RTP {} packet(s) to {}",
711                    sent,
712                    codec.rtpmap_name(),
713                    target
714                ));
715            })
716            .await;
717    }
718}
719
720/// True if this action is one of the six SIP response verbs, i.e. something
721/// [`SipServer::build_sip_response`] can turn into a status line.
722///
723/// The reply path selects on this rather than taking the first action the model returned: a
724/// common action (`set_memory`, `schedule_task`, …) returned alongside the reply is executed by
725/// the shared executor and must not be mistaken for one.
726fn is_sip_response_action(action: &serde_json::Value) -> bool {
727    matches!(
728        action.get("type").and_then(|v| v.as_str()),
729        Some("sip_register" | "sip_invite" | "sip_bye" | "sip_ack" | "sip_options" | "sip_cancel")
730    )
731}
732
733/// Extract the media target (connection IP + audio port) from an SDP offer.
734///
735/// Reads the `c=IN IP4 <ip>` connection line and the first `m=audio <port> ...` line. Returns
736/// None if either is missing or unparseable.
737fn parse_sdp_audio_target(sdp: &str) -> Option<(std::net::IpAddr, u16)> {
738    let mut ip: Option<std::net::IpAddr> = None;
739    let mut port: Option<u16> = None;
740    for line in sdp.lines() {
741        let line = line.trim();
742        if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
743            ip = rest
744                .trim()
745                .split('/')
746                .next()
747                .and_then(|s| s.trim().parse().ok());
748        } else if let Some(rest) = line.strip_prefix("m=audio ") {
749            port = rest.split_whitespace().next().and_then(|s| s.parse().ok());
750        }
751    }
752    Some((ip?, port?))
753}
754
755/// Parsed SIP message
756#[derive(Debug, Clone)]
757struct SipMessage {
758    method: String,
759    #[allow(dead_code)]
760    request_uri: String,
761    call_id: String,
762    from: String,
763    to: String,
764    via: Vec<String>,
765    cseq: String,
766    contact: Option<String>,
767    expires: Option<u32>,
768    #[allow(dead_code)]
769    content_type: Option<String>,
770    body: Option<String>,
771}