Skip to main content

netget/server/sip/
actions.rs

1//! SIP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct SipProtocol;
15
16impl SipProtocol {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22// Implement Protocol trait (common functionality)
23impl Protocol for SipProtocol {
24    /// None. SIP has no user-triggered action that does anything.
25    ///
26    /// `send_sip_invite`, `send_sip_bye` and `update_registration` used to be advertised
27    /// here. All three were `Ok(ActionResult::NoAction)` stubs — every parameter they
28    /// declared (`to`, `from`, `sdp`, `call_id`, `bindings`) was read by nothing, and neither
29    /// the action names nor those fields appear anywhere in `mod.rs`. So a user asking to
30    /// place or end a call got silence, and the model was told the capability existed.
31    ///
32    /// Implementing them needs outbound dialog state this server does not keep (it answers
33    /// requests; it does not originate them) and a registration database that outlives a
34    /// request. Removed rather than left advertised, as the proxy's six configuration actions
35    /// and `ssh_agent/modify_instruction` were: an action that silently does nothing is worse
36    /// than an absent one, because the model will choose it.
37    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
38        vec![]
39    }
40    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
41        vec![
42            sip_register_action(),
43            sip_invite_action(),
44            sip_bye_action(),
45            sip_ack_action(),
46            sip_options_action(),
47            sip_cancel_action(),
48        ]
49    }
50    fn protocol_name(&self) -> &'static str {
51        "SIP"
52    }
53    fn get_event_types(&self) -> Vec<EventType> {
54        get_sip_event_types()
55    }
56    fn stack_name(&self) -> &'static str {
57        "ETH>IP>UDP>SIP"
58    }
59    fn keywords(&self) -> Vec<&'static str> {
60        vec!["sip", "voip", "session initiation"]
61    }
62    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
63        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
64
65        ProtocolMetadataV2::builder()
66            .connectionless()
67            // Answers on failure: 503 Service Unavailable; nothing reaches a 2xx unless the model
68            // names one.
69            .answers_on_failure()
70            .state(DevelopmentState::Beta)
71            .well_known_udp_port(5060)
72            // Not rsipstack, and not a compliant stack. `Cargo.toml` declares `sip = []` -
73            // there is no SIP dependency at all. `mod.rs` hand-parses the request line and
74            // headers and hand-builds the status line. The old claim named a library version
75            // this build has never linked, which is exactly the sort of thing an operator
76            // reads as evidence of maturity.
77            .implementation(
78                "Manual line-based parser and response builder (no SIP library; Cargo declares \
79                 `sip = []`). Request line + headers + optional SDP body only: no transaction \
80                 layer, no retransmissions, no digest auth, no dialog state, UDP only",
81            )
82            .llm_control("Registration decisions + call routing + SDP generation")
83            // Beta since September 2026. This comment used to say the evidence was "a
84            // mocked-LLM UDP exchange plus netget's own SIP client - which makes it circular,
85            // and is why this stays Experimental". That was true when written; sipsak is now
86            // installed and drives the server from outside, which is what changed.
87            .e2e_testing(
88                "REAL THIRD-PARTY CLIENT: sipsak 0.9.8.1 (the FhG Fokus SER torture-tester, a \
89                 C program) in tests/server/sip/real_client_test.rs. NOT #[ignore]d and NOT \
90                 skip-gated — the tests FAIL, naming `brew install sipsak`, when the binary is \
91                 absent. Not circular: `sip = []` in Cargo.toml, so this server is hand-written \
92                 over tokio's UdpSocket with no SIP library at all. Three transactions: an \
93                 OPTIONS answered 200 with the model's own allow_methods (sipsak exits 0 only \
94                 after matching the reply's Via+branch, Call-ID and CSeq against the request it \
95                 sent — the correlation a dissector cannot check, and the thing that decides \
96                 whether a real UA accepts a reply or discards it as unmatched); the same \
97                 OPTIONS answered 403, where sipsak reports the code and exits non-zero, \
98                 proving it reads our status line rather than merely receiving a datagram; and \
99                 a REGISTER through usrloc mode, which sipsak reports as 'All usrloc tests \
100                 completed successful.' Since build_sip_response is ONE function for every \
101                 method, the correlation proven on OPTIONS is the same code every reply uses. \
102                 Alongside: the mocked-LLM UDP e2e over the six methods, whose replies are \
103                 additionally checked with the Wireshark `sip` dissector, plus a fail-closed \
104                 503/ACK-silence test and an RTP interop test. \
105                 NOT PROVEN: INVITE and its SDP against a real UA (sipsak's -I mode calls \
106                 itself and needs a media stack; no softphone is driven here), so call setup is \
107                 covered only by the dissector; digest authentication, which is NOT IMPLEMENTED \
108                 and which no action can produce, so no client can be made to authenticate; \
109                 TCP and TLS transport (UDP only); dialogs, CANCEL, and multi-Via proxy paths. \
110                 Also note sipsak's usrloc mode deactivates Via insertion, so the REGISTER test \
111                 contributes the admission decision and NOT correlation evidence.",
112            )
113            .notes(
114                "Scripting candidate, VoIP signaling honeypot. No registration database: the \
115                 model decides each REGISTER on its own and nothing is stored between requests \
116                 (protocols must not implement storage). REGISTER and INVITE are admission \
117                 decisions and fail closed - an LLM error is 503, an action with no \
118                 status_code or no SIP response action at all is 500, never a defaulted 200",
119            )
120            .build()
121    }
122    fn description(&self) -> &'static str {
123        "SIP server for VoIP signaling"
124    }
125    fn example_prompt(&self) -> &'static str {
126        "Start a SIP server on port 5060 for VoIP registration and call signaling"
127    }
128    fn group_name(&self) -> &'static str {
129        "Proxy & Network"
130    }
131
132    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
133        use crate::llm::actions::StartupExamples;
134
135        // Deterministic: answer REGISTER, INVITE and OPTIONS each with 200 OK,
136        // no LLM call. One script handles all three events.
137        let script = r#"import json, sys
138data = json.load(sys.stdin)
139event = data["event"]
140et = data["event_type_id"]
141if et == "sip_register":
142    actions = [{"type": "sip_register", "status_code": 200}]
143elif et == "sip_invite":
144    actions = [{"type": "sip_invite", "status_code": 200}]
145elif et == "sip_options":
146    actions = [{"type": "sip_options", "status_code": 200}]
147else:
148    actions = []
149print(json.dumps({"actions": actions}))"#;
150
151        StartupExamples::new(
152            // LLM mode
153            json!({
154                "type": "open_server",
155                "port": 5060,
156                "base_stack": "sip",
157                "instruction": "SIP VoIP signaling server. Accept REGISTER requests with 200 OK. For INVITE requests, respond with 200 OK and SDP. Log all call setup attempts."
158            }),
159            // Script mode
160            json!({
161                "type": "open_server",
162                "port": 5060,
163                "base_stack": "sip",
164                "event_handlers": [{
165                    "event_pattern": "sip_register",
166                    "handler": {
167                        "type": "script",
168                        "language": "python",
169                        "code": script
170                    }
171                }, {
172                    "event_pattern": "sip_invite",
173                    "handler": {
174                        "type": "script",
175                        "language": "python",
176                        "code": script
177                    }
178                }, {
179                    "event_pattern": "sip_options",
180                    "handler": {
181                        "type": "script",
182                        "language": "python",
183                        "code": script
184                    }
185                }]
186            }),
187            // Static mode
188            json!({
189                "type": "open_server",
190                "port": 5060,
191                "base_stack": "sip",
192                "event_handlers": [{
193                    "event_pattern": "sip_register",
194                    "handler": {
195                        "type": "static",
196                        "actions": [{
197                            "type": "sip_register",
198                            "status_code": 200,
199                            "expires": 3600
200                        }]
201                    }
202                }]
203            }),
204        )
205    }
206}
207
208// Implement Server trait (server-specific functionality)
209impl Server for SipProtocol {
210    fn spawn(
211        &self,
212        ctx: crate::protocol::SpawnContext,
213    ) -> std::pin::Pin<
214        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
215    > {
216        Box::pin(async move {
217            use crate::server::sip::SipServer;
218            SipServer::spawn_with_llm_actions(
219                ctx.legacy_listen_addr(),
220                ctx.llm_client,
221                ctx.state,
222                ctx.status_tx,
223                ctx.server_id,
224            )
225            .await
226        })
227    }
228    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
229        let action_type = action
230            .get("type")
231            .and_then(|v| v.as_str())
232            .context("Missing 'type' field in action")?;
233
234        match action_type {
235            "sip_register" => self.execute_sip_register(action),
236            "sip_invite" => self.execute_sip_invite(action),
237            "sip_bye" => self.execute_sip_bye(action),
238            "sip_ack" => Ok(ActionResult::NoAction), // ACK doesn't require response
239            "sip_options" => self.execute_sip_options(action),
240            "sip_cancel" => self.execute_sip_cancel(action),
241            _ => Err(anyhow::anyhow!("Unknown SIP action: {}", action_type)),
242        }
243    }
244}
245
246impl SipProtocol {
247    /// Execute SIP REGISTER action (network event)
248    /// Just validates the action - actual response data comes from action JSON itself
249    fn execute_sip_register(&self, _action: serde_json::Value) -> Result<ActionResult> {
250        // Validation happens in mod.rs when parsing the action
251        Ok(ActionResult::NoAction)
252    }
253
254    /// Execute SIP INVITE action (network event)
255    fn execute_sip_invite(&self, _action: serde_json::Value) -> Result<ActionResult> {
256        Ok(ActionResult::NoAction)
257    }
258
259    /// Execute SIP BYE action (network event)
260    fn execute_sip_bye(&self, _action: serde_json::Value) -> Result<ActionResult> {
261        Ok(ActionResult::NoAction)
262    }
263
264    /// Execute SIP OPTIONS action (network event)
265    fn execute_sip_options(&self, _action: serde_json::Value) -> Result<ActionResult> {
266        Ok(ActionResult::NoAction)
267    }
268
269    /// Execute SIP CANCEL action (network event)
270    fn execute_sip_cancel(&self, _action: serde_json::Value) -> Result<ActionResult> {
271        Ok(ActionResult::NoAction)
272    }
273}
274
275/// SIP event types
276fn get_sip_event_types() -> Vec<EventType> {
277    vec![
278        SIP_REGISTER_EVENT.clone(),
279        SIP_INVITE_EVENT.clone(),
280        SIP_BYE_EVENT.clone(),
281        SIP_ACK_EVENT.clone(),
282        SIP_OPTIONS_EVENT.clone(),
283        SIP_CANCEL_EVENT.clone(),
284    ]
285}
286
287/// SIP REGISTER event
288pub static SIP_REGISTER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
289    EventType::new(
290        "sip_register",
291        "A client is registering its location with REGISTER. Answer with sip_register: 200 to \
292         accept the binding, 401/403 to challenge or refuse it.",
293        json!({
294            "type": "sip_register",
295            "status_code": 200,
296            "expires": 3600
297        }),
298    )
299    // Each SIP event is answered by exactly one action - the reply to a REGISTER is a REGISTER
300    // response and nothing else - so every event lists only its own. `call_llm` builds the
301    // model's tool list from the event type rather than from get_sync_actions(), so before this
302    // the model was offered none of the six and every reply it produced was rejected.
303    .with_actions(vec![sip_register_action()])
304    .with_log_template(
305        LogTemplate::new()
306            .with_info("SIP REGISTER")
307            .with_debug("SIP REGISTER request")
308            .with_trace("SIP: {json_pretty(.)}"),
309    )
310});
311
312/// SIP INVITE event
313pub static SIP_INVITE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
314    EventType::new(
315        "sip_invite",
316        "A client is initiating a session with INVITE. Answer with sip_invite carrying the status \
317         code and, for a 200, the answering SDP.",
318        json!({
319            "type": "sip_invite",
320            "status_code": 200,
321            "sdp": "v=0\no=- 0 0 IN IP4 127.0.0.1\ns=Call\nc=IN IP4 127.0.0.1\nt=0 0\nm=audio 8000 RTP/AVP 0\n"
322        }),
323    )
324    .with_actions(vec![sip_invite_action()])
325    .with_log_template(
326        LogTemplate::new()
327            .with_info("SIP INVITE")
328            .with_debug("SIP INVITE request")
329            .with_trace("SIP: {json_pretty(.)}"),
330    )
331});
332
333/// SIP BYE event
334pub static SIP_BYE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
335    EventType::new(
336        "sip_bye",
337        "The far end is terminating the session with BYE. Answer with sip_bye; 200 acknowledges \
338         the teardown.",
339        json!({"type": "sip_bye", "status_code": 200}),
340    )
341    .with_actions(vec![sip_bye_action()])
342    .with_log_template(
343        LogTemplate::new()
344            .with_info("SIP BYE")
345            .with_debug("SIP BYE request")
346            .with_trace("SIP: {json_pretty(.)}"),
347    )
348});
349
350/// SIP ACK event
351pub static SIP_ACK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
352    EventType::new(
353        "sip_ack",
354        "The client acknowledged an INVITE response. ACK is never answered on the wire, so \
355         sip_ack simply records that the dialog is established.",
356        json!({"type": "sip_ack"}),
357    )
358    .with_actions(vec![sip_ack_action()])
359    .with_log_template(
360        LogTemplate::new()
361            .with_info("SIP ACK")
362            .with_debug("SIP ACK request")
363            .with_trace("SIP: {json_pretty(.)}"),
364    )
365});
366
367/// The reason phrase RFC 3261 section 21 gives a status code, used when an action names a
368/// code and no `reason_phrase`. A class's generic phrase stands in for a code the table does
369/// not list, so a 486 is never written "486 OK".
370pub fn default_reason_phrase(code: u16) -> &'static str {
371    match code {
372        100 => "Trying",
373        180 => "Ringing",
374        181 => "Call Is Being Forwarded",
375        182 => "Queued",
376        183 => "Session Progress",
377        200 => "OK",
378        202 => "Accepted",
379        300 => "Multiple Choices",
380        301 => "Moved Permanently",
381        302 => "Moved Temporarily",
382        305 => "Use Proxy",
383        380 => "Alternative Service",
384        400 => "Bad Request",
385        401 => "Unauthorized",
386        402 => "Payment Required",
387        403 => "Forbidden",
388        404 => "Not Found",
389        405 => "Method Not Allowed",
390        406 => "Not Acceptable",
391        407 => "Proxy Authentication Required",
392        408 => "Request Timeout",
393        410 => "Gone",
394        413 => "Request Entity Too Large",
395        414 => "Request-URI Too Long",
396        415 => "Unsupported Media Type",
397        416 => "Unsupported URI Scheme",
398        420 => "Bad Extension",
399        421 => "Extension Required",
400        423 => "Interval Too Brief",
401        480 => "Temporarily Unavailable",
402        481 => "Call/Transaction Does Not Exist",
403        482 => "Loop Detected",
404        483 => "Too Many Hops",
405        484 => "Address Incomplete",
406        485 => "Ambiguous",
407        486 => "Busy Here",
408        487 => "Request Terminated",
409        488 => "Not Acceptable Here",
410        491 => "Request Pending",
411        493 => "Undecipherable",
412        500 => "Server Internal Error",
413        501 => "Not Implemented",
414        502 => "Bad Gateway",
415        503 => "Service Unavailable",
416        504 => "Server Time-out",
417        505 => "Version Not Supported",
418        513 => "Message Too Large",
419        600 => "Busy Everywhere",
420        603 => "Decline",
421        604 => "Does Not Exist Anywhere",
422        606 => "Not Acceptable",
423        100..=199 => "Session Progress",
424        200..=299 => "OK",
425        300..=399 => "Redirection",
426        400..=499 => "Request Failure",
427        500..=599 => "Server Failure",
428        _ => "Global Failure",
429    }
430}
431
432/// The `answer_with` field of `sip_options`.
433///
434/// The event used to say "answer with sip_options listing the methods you support", and the
435/// action's only example is a 200. Told "you are a SIP phone in do-not-disturb mode; tell
436/// anyone who checks on you that you are busy", llama3.1:8b answered sipsak's OPTIONS with 200
437/// OK five times in five. RFC 3261 section 11.2 makes the OPTIONS status the one an INVITE
438/// would get, so the request says so, with the codes that carry each situation.
439pub const OPTIONS_ANSWER_WITH: &str = "sip_options with the status an INVITE would get right \
440     now: status_code 200 if your instructions say this endpoint is available to take a call; \
441     486 (Busy Here) if they say it is busy, on another call or in do-not-disturb; 480 \
442     (Temporarily Unavailable) if they say it is offline or away; 603 (Decline) if it refuses \
443     every call";
444
445/// SIP OPTIONS event
446pub static SIP_OPTIONS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
447    EventType::new(
448        "sip_options",
449        "A client is checking on this endpoint with OPTIONS - whether it is reachable and \
450         what it supports. RFC 3261 section 11.2: the status is the one an INVITE would get \
451         right now, so 200 only when the endpoint would take a call; answer_with says which \
452         code fits which situation.",
453        json!({
454            "type": "sip_options",
455            "status_code": 200,
456            "allow_methods": ["INVITE", "ACK", "BYE", "REGISTER", "OPTIONS"]
457        }),
458    )
459    .with_parameters(vec![Parameter {
460        name: "answer_with".to_string(),
461        type_hint: "string".to_string(),
462        description: "Which status this OPTIONS takes: the one an INVITE would get now - 200 \
463                      available, 486 busy or do-not-disturb, 480 offline. Present for an \
464                      OPTIONS request; absent for a method this server does not recognise"
465            .to_string(),
466        required: false,
467    }])
468    .with_actions(vec![sip_options_action()])
469    .with_log_template(
470        LogTemplate::new()
471            .with_info("SIP OPTIONS")
472            .with_debug("SIP OPTIONS request")
473            .with_trace("SIP: {json_pretty(.)}"),
474    )
475});
476
477/// SIP CANCEL event
478pub static SIP_CANCEL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
479    EventType::new(
480        "sip_cancel",
481        "The client is cancelling a pending INVITE. Answer with sip_cancel; 200 confirms the \
482         cancellation.",
483        json!({"type": "sip_cancel", "status_code": 200}),
484    )
485    .with_actions(vec![sip_cancel_action()])
486    .with_log_template(
487        LogTemplate::new()
488            .with_info("SIP CANCEL")
489            .with_debug("SIP CANCEL request")
490            .with_trace("SIP: {json_pretty(.)}"),
491    )
492});
493
494// Action definitions
495fn sip_register_action() -> ActionDefinition {
496    ActionDefinition {
497        name: "sip_register".to_string(),
498        description: "Respond to SIP REGISTER request".to_string(),
499        parameters: vec![
500            Parameter {
501                name: "status_code".to_string(),
502                type_hint: "number".to_string(),
503                description: "SIP status code (200=OK, 403=Forbidden, 401=Unauthorized)"
504                    .to_string(),
505                required: true,
506            },
507            Parameter {
508                name: "reason_phrase".to_string(),
509                type_hint: "string".to_string(),
510                description: "Optional reason phrase (default based on status code)".to_string(),
511                required: false,
512            },
513            Parameter {
514                name: "expires".to_string(),
515                type_hint: "number".to_string(),
516                description: "Registration expiration in seconds (default 3600)".to_string(),
517                required: false,
518            },
519        ],
520        example: json!({
521            "type": "sip_register",
522            "status_code": 200,
523            "expires": 3600
524        }),
525        log_template: Some(
526            LogTemplate::new()
527                .with_info("-> SIP {status_code} REGISTER")
528                .with_debug("SIP sip_register: status={status_code}, expires={expires}"),
529        ),
530    }
531}
532
533fn sip_invite_action() -> ActionDefinition {
534    ActionDefinition {
535        name: "sip_invite".to_string(),
536        description: "Respond to SIP INVITE request".to_string(),
537        parameters: vec![
538            Parameter {
539                name: "status_code".to_string(),
540                type_hint: "number".to_string(),
541                description: "SIP status code (200=OK, 486=Busy, 603=Decline, 180=Ringing)"
542                    .to_string(),
543                required: true,
544            },
545            Parameter {
546                name: "reason_phrase".to_string(),
547                type_hint: "string".to_string(),
548                description: "Optional reason phrase".to_string(),
549                required: false,
550            },
551            Parameter {
552                name: "sdp".to_string(),
553                type_hint: "string".to_string(),
554                description: "Session Description Protocol body (required for 200 OK)".to_string(),
555                required: false,
556            },
557            Parameter {
558                name: "rtp_audio".to_string(),
559                type_hint: "object".to_string(),
560                description: "Optional media to actually stream on a 200 OK: {content, tone_hz, \
561                              payload_type, duration_ms}. When NetGet is built with the `rtp` \
562                              feature, accepting a call streams this as real RTP to the m=audio \
563                              target in the caller's INVITE SDP, so the negotiated session carries \
564                              media rather than only promising it. Ignored without the `rtp` feature."
565                    .to_string(),
566                required: false,
567            },
568        ],
569        example: json!({
570            "type": "sip_invite",
571            "status_code": 200,
572            "sdp": "v=0\no=- 0 0 IN IP4 127.0.0.1\ns=Call\nc=IN IP4 127.0.0.1\nt=0 0\nm=audio 8000 RTP/AVP 0\n",
573            "rtp_audio": {"content": "tone", "tone_hz": 440, "payload_type": "pcmu", "duration_ms": 2000}
574        }),
575        log_template: Some(
576            LogTemplate::new()
577                .with_info("-> SIP {status_code} INVITE")
578                .with_debug("SIP sip_invite: status={status_code}"),
579        ),
580    }
581}
582
583fn sip_bye_action() -> ActionDefinition {
584    ActionDefinition {
585        name: "sip_bye".to_string(),
586        description: "Respond to SIP BYE request".to_string(),
587        parameters: vec![Parameter {
588            name: "status_code".to_string(),
589            type_hint: "number".to_string(),
590            // Required, and there is no default. `build_sip_response` answers 500 when the
591            // field is absent rather than defaulting to 200 - a defaulted 200 would turn a
592            // forgotten field into an acceptance. The description used to promise a 200
593            // default the executor has never applied.
594            description: "SIP status code; 200 acknowledges the teardown. Required - an action \
595                          with no status_code is answered 500, not 200"
596                .to_string(),
597            required: true,
598        }],
599        example: json!({
600            "type": "sip_bye",
601            "status_code": 200
602        }),
603        log_template: Some(
604            LogTemplate::new()
605                .with_info("-> SIP {status_code} BYE")
606                .with_debug("SIP sip_bye: status={status_code}"),
607        ),
608    }
609}
610
611fn sip_ack_action() -> ActionDefinition {
612    ActionDefinition {
613        name: "sip_ack".to_string(),
614        description: "Process SIP ACK (no response needed)".to_string(),
615        parameters: vec![],
616        example: json!({
617            "type": "sip_ack"
618        }),
619        log_template: Some(
620            LogTemplate::new()
621                .with_info("-> SIP ACK processed")
622                .with_debug("SIP sip_ack"),
623        ),
624    }
625}
626
627fn sip_options_action() -> ActionDefinition {
628    ActionDefinition {
629        name: "sip_options".to_string(),
630        description: "Answer an OPTIONS request. Its status is the one an INVITE would get \
631                      right now (RFC 3261 section 11.2): 200 when this endpoint is available, \
632                      486 Busy Here when it is busy or in do-not-disturb, 480 Temporarily \
633                      Unavailable when it is offline, 600 Busy Everywhere or 603 Decline when \
634                      it refuses every call."
635            .to_string(),
636        parameters: vec![
637            Parameter {
638                name: "status_code".to_string(),
639                type_hint: "number".to_string(),
640                description: "SIP status code: 200 available, 486 busy / do-not-disturb, 480 \
641                              unavailable, 603 decline. Required - an action with no \
642                              status_code is answered 500, not 200"
643                    .to_string(),
644                required: true,
645            },
646            Parameter {
647                name: "allow_methods".to_string(),
648                type_hint: "array".to_string(),
649                description: "Array of supported SIP methods".to_string(),
650                required: false,
651            },
652        ],
653        example: json!({
654            "type": "sip_options",
655            "status_code": 200,
656            "allow_methods": ["INVITE", "ACK", "BYE", "REGISTER", "OPTIONS"]
657        }),
658        log_template: Some(
659            LogTemplate::new()
660                .with_info("-> SIP {status_code} OPTIONS")
661                .with_debug("SIP sip_options: status={status_code}, methods={allow_methods_len}"),
662        ),
663    }
664}
665
666fn sip_cancel_action() -> ActionDefinition {
667    ActionDefinition {
668        name: "sip_cancel".to_string(),
669        description: "Respond to SIP CANCEL request".to_string(),
670        parameters: vec![Parameter {
671            name: "status_code".to_string(),
672            type_hint: "number".to_string(),
673            description: "SIP status code; 200 confirms the cancellation. Required - an action \
674                          with no status_code is answered 500, not 200"
675                .to_string(),
676            required: true,
677        }],
678        example: json!({
679            "type": "sip_cancel",
680            "status_code": 200
681        }),
682        log_template: Some(
683            LogTemplate::new()
684                .with_info("-> SIP {status_code} CANCEL")
685                .with_debug("SIP sip_cancel: status={status_code}"),
686        ),
687    }
688}
689
690// User-triggered actions