Skip to main content

netget/server/m3ua/
ss7.rs

1//! The SS7 upper stack over M3UA: SCCP → TCAP → MAP / CAP, as one dialogue engine shared by the
2//! SGP (`mod.rs`) and the ASP client (`src/client/m3ua`).
3//!
4//! An M3UA DATA whose Service Indicator is 3 carries SCCP. [`Engine::on_data`] decodes the SCCP
5//! unitdata, the TCAP message inside it and the MAP or CAP components inside that, keeps the
6//! transaction table, and either answers in Rust (everything that follows from the protocol:
7//! SCCP management, a P-Abort for an unknown transaction, a Reject for an operation NetGet does
8//! not implement, a refused segmented message) or hands back one structured event for the model.
9//!
10//! The model's answer comes back as an intent (`send_map_result`, `send_cap_operations`, …,
11//! carried as `ActionResult::Custom`) which [`Engine::apply`] turns into TCAP for that dialogue:
12//! the right message type, the peer's transaction id, the AARE if the dialogue still needs one,
13//! the invoke id it answers, the swapped SCCP addresses and MTP routing label.
14//!
15//! # Bounds
16//!
17//! | Bound | Value | On breach |
18//! |---|---|---|
19//! | Open dialogues | [`MAX_OPEN_DIALOGUES`] per association | TC-P-ABORT `resourceLimitation` |
20//! | Dialogue idle | [`DIALOGUE_IDLE_TIMEOUT`] | TC-U-ABORT to the peer, dialogue dropped |
21//! | Components per message | `tcap::MAX_COMPONENTS` | TC-P-ABORT `resourceLimitation` |
22//! | BER depth / elements | `ber::MAX_BER_DEPTH` / `ber::MAX_BER_ELEMENTS` | TC-P-ABORT `badlyFormattedTransactionPortion` |
23//! | SCCP data | 255 octets (UDT/XUDT) | outgoing: the action fails; incoming: cannot occur |
24//! | Segmentation | refused | UDTS/XUDTS `segmentation not supported` when the sender asked for errors |
25
26use super::ber;
27use super::codec::ProtocolData;
28use super::sccp::{self, SccpAddress, Scmg, Unitdata};
29use super::tcap::{self, Code, Component, DialoguePdu, TcKind, TcMessage};
30use super::{cap, gsm, map};
31use crate::utils::clock::Instant;
32use serde_json::{json, Value};
33use std::collections::HashMap;
34use std::time::Duration;
35
36/// Open TCAP dialogues kept per association.
37pub const MAX_OPEN_DIALOGUES: usize = 1024;
38/// A dialogue with no traffic for this long is aborted and forgotten.
39pub const DIALOGUE_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
40/// The M3UA Service Indicator for SCCP.
41pub const SI_SCCP: u8 = 3;
42/// `ActionResult::Custom` name for an SS7 intent.
43pub const SS7_INTENT: &str = "ss7_intent";
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum Family {
47    Map,
48    Cap,
49}
50
51impl Family {
52    pub fn name(self) -> &'static str {
53        match self {
54            Family::Map => "map",
55            Family::Cap => "cap",
56        }
57    }
58}
59
60/// How to reach the far end of a dialogue: the MTP routing label, the SCCP addresses and the
61/// M3UA parameters to put on the DATA.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Route {
64    pub opc: u32,
65    pub dpc: u32,
66    pub ni: u8,
67    pub mp: u8,
68    pub sls: u8,
69    /// The far end's address (our Called Party Address).
70    pub remote: SccpAddress,
71    /// Ours (our Calling Party Address).
72    pub local: SccpAddress,
73    /// UDT or XUDT, mirroring what the far end used.
74    pub message_type: u8,
75    pub protocol_class: u8,
76    pub routing_context: Option<u32>,
77    pub network_appearance: Option<u32>,
78}
79
80impl Route {
81    /// The route back to whoever sent `pd`/`ud`.
82    pub fn reply_to(
83        pd: &ProtocolData,
84        ud: &Unitdata,
85        routing_context: Option<u32>,
86        network_appearance: Option<u32>,
87    ) -> Route {
88        Route {
89            opc: pd.dpc,
90            dpc: pd.opc,
91            ni: pd.ni,
92            mp: pd.mp,
93            sls: pd.sls,
94            remote: ud.calling.clone(),
95            local: ud.called.clone(),
96            message_type: if ud.message_type == sccp::MSG_XUDT {
97                sccp::MSG_XUDT
98            } else {
99                sccp::MSG_UDT
100            },
101            protocol_class: ud.protocol_class & 0x01,
102            routing_context,
103            network_appearance,
104        }
105    }
106
107    fn to_json(&self) -> Value {
108        json!({
109            "opc": self.opc,
110            "dpc": self.dpc,
111            "sls": self.sls,
112            "remote_address": self.remote.to_json(),
113            "local_address": self.local.to_json(),
114        })
115    }
116}
117
118/// One message to put on the association.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct Outgoing {
121    pub protocol_data: ProtocolData,
122    pub routing_context: Option<u32>,
123    pub network_appearance: Option<u32>,
124    /// What it is, for the log.
125    pub label: String,
126}
127
128fn wrap(route: &Route, sccp_bytes: Vec<u8>, label: String) -> Outgoing {
129    Outgoing {
130        protocol_data: ProtocolData {
131            opc: route.opc,
132            dpc: route.dpc,
133            si: SI_SCCP,
134            ni: route.ni,
135            mp: route.mp,
136            sls: route.sls,
137            payload: sccp_bytes,
138        },
139        routing_context: route.routing_context,
140        network_appearance: route.network_appearance,
141        label,
142    }
143}
144
145fn unitdata(route: &Route, tcap_bytes: &[u8]) -> Result<Vec<u8>, String> {
146    if route.message_type == sccp::MSG_XUDT {
147        sccp::encode_xudt(
148            route.protocol_class,
149            false,
150            sccp::DEFAULT_HOP_COUNTER,
151            &route.remote,
152            &route.local,
153            tcap_bytes,
154        )
155    } else {
156        sccp::encode_udt(
157            route.protocol_class,
158            false,
159            &route.remote,
160            &route.local,
161            tcap_bytes,
162        )
163    }
164}
165
166fn send_tcap(route: &Route, m: &TcMessage, label: String) -> Result<Outgoing, String> {
167    Ok(wrap(route, unitdata(route, &m.encode())?, label))
168}
169
170/// One TCAP dialogue.
171#[derive(Clone, Debug)]
172pub struct Dialogue {
173    pub id: u32,
174    pub remote_tid: Option<Vec<u8>>,
175    pub acn: Option<Vec<u64>>,
176    pub family: Family,
177    /// The peer opened with an AARQ that has not been answered with an AARE yet.
178    pub aare_pending: bool,
179    pub route: Route,
180    pub last_activity: Instant,
181    /// Invokes from the peer still awaiting a result or error: (invoke id, operation code).
182    pub peer_invokes: Vec<(i64, i64)>,
183    /// Invokes NetGet sent: (invoke id, operation code).
184    pub own_invokes: Vec<(i64, i64)>,
185    pub next_invoke_id: i64,
186    /// The last thing the peer sent asked for an answer and none has gone out yet.
187    pub awaiting_answer: bool,
188    /// Follow-up depth (client): how many model turns led to this dialogue.
189    pub depth: u32,
190}
191
192impl Dialogue {
193    fn tid(&self) -> Vec<u8> {
194        self.id.to_be_bytes().to_vec()
195    }
196
197    fn take_invoke_id(&mut self) -> i64 {
198        let id = self.next_invoke_id;
199        self.next_invoke_id = if id >= 127 { 1 } else { id + 1 };
200        id
201    }
202}
203
204/// The outcome of one inbound DATA.
205#[derive(Debug)]
206pub enum Inbound {
207    /// Not SCCP; the raw M3UA path applies.
208    NotSccp,
209    /// SCCP NetGet could not decode as connectionless unitdata; the raw path applies, with why.
210    Undecoded(String),
211    /// Answered (or deliberately ignored) in Rust. `note` says what happened, for the log.
212    Handled {
213        replies: Vec<Outgoing>,
214        note: String,
215    },
216    /// Raise this event.
217    Event(Ss7Event),
218}
219
220#[derive(Debug)]
221pub struct Ss7Event {
222    pub family: Family,
223    pub dialogue_id: u32,
224    pub kind: TcKind,
225    pub data: Value,
226    /// Rust-side replies that go out before the model is asked (none today; kept for order).
227    pub replies: Vec<Outgoing>,
228    /// The dialogue ended with this message (End/Abort); nothing can be sent in it.
229    pub ended: bool,
230    pub depth: u32,
231}
232
233/// The transaction table and everything that reads or writes it.
234pub struct Engine {
235    dialogues: HashMap<u32, Dialogue>,
236    next_tid: u32,
237    /// Open dialogues allowed; [`MAX_OPEN_DIALOGUES`] unless a test lowers it.
238    pub max_dialogues: usize,
239    /// The route back to whoever sent the last SCCP message, for a dialogue NetGet opens.
240    pub last_route: Option<Route>,
241}
242
243impl Default for Engine {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl Engine {
250    pub fn new() -> Self {
251        // Start the local transaction ids away from zero so they read as identifiers.
252        let seed = crate::utils::clock::process_id().wrapping_mul(2654435761) & 0x00ff_ffff;
253        Engine {
254            dialogues: HashMap::new(),
255            next_tid: 0x0100_0000 | seed,
256            max_dialogues: MAX_OPEN_DIALOGUES,
257            last_route: None,
258        }
259    }
260
261    pub fn open_dialogues(&self) -> usize {
262        self.dialogues.len()
263    }
264
265    pub fn dialogue(&self, id: u32) -> Option<&Dialogue> {
266        self.dialogues.get(&id)
267    }
268
269    fn allocate(&mut self) -> u32 {
270        loop {
271            let id = self.next_tid;
272            self.next_tid = self.next_tid.wrapping_add(1).max(1);
273            if !self.dialogues.contains_key(&id) {
274                return id;
275            }
276        }
277    }
278
279    fn find_by_local_tid(&mut self, tid: &[u8]) -> Option<&mut Dialogue> {
280        if tid.len() != 4 {
281            return None;
282        }
283        let id = u32::from_be_bytes([tid[0], tid[1], tid[2], tid[3]]);
284        self.dialogues.get_mut(&id)
285    }
286
287    /// Decode one inbound DATA's user part.
288    pub fn on_data(
289        &mut self,
290        pd: &ProtocolData,
291        routing_context: Option<u32>,
292        network_appearance: Option<u32>,
293        now: Instant,
294    ) -> Inbound {
295        if pd.si != SI_SCCP {
296            return Inbound::NotSccp;
297        }
298        let ud = match sccp::decode(&pd.payload) {
299            Ok(u) => u,
300            Err(e) => return Inbound::Undecoded(e),
301        };
302        let route = Route::reply_to(pd, &ud, routing_context, network_appearance);
303        self.last_route = Some(route.clone());
304
305        if ud.is_service_message() {
306            return self.on_service_message(&ud);
307        }
308        if ud.called.ssn == Some(sccp::SSN_SCMG) {
309            return on_scmg(&ud, &route);
310        }
311        if let Some(seg) = ud.segmentation {
312            if !seg.is_whole_message() {
313                let mut replies = Vec::new();
314                if ud.return_on_error {
315                    if let Ok(b) = sccp::encode_service(&ud, sccp::CAUSE_SEGMENTATION_NOT_SUPPORTED)
316                    {
317                        replies.push(wrap(&route, b, "XUDTS segmentation not supported".into()));
318                    }
319                }
320                return Inbound::Handled {
321                    replies,
322                    note: format!(
323                        "decision=refused_segmented: XUDT segment (first={}, remaining={}) — \
324                         NetGet does not reassemble",
325                        seg.first, seg.remaining
326                    ),
327                };
328            }
329        }
330        let message = match TcMessage::decode(&ud.data) {
331            Ok(m) => m,
332            Err((cause, e)) => return refuse_undecodable(&ud.data, &route, cause, e),
333        };
334        match message.kind {
335            TcKind::Begin => self.on_begin(message, route, now),
336            TcKind::Continue => self.on_continue(message, route, now),
337            TcKind::End | TcKind::Abort => self.on_end_or_abort(message, now),
338            TcKind::Unidirectional => Inbound::Handled {
339                replies: vec![],
340                note: "TC-UNI ignored: NetGet answers dialogues, and a unidirectional message \
341                       cannot be answered"
342                    .into(),
343            },
344        }
345    }
346
347    fn on_service_message(&mut self, ud: &Unitdata) -> Inbound {
348        let cause = ud.return_cause.unwrap_or(0);
349        // The returned data is one of our own messages; its originating id names the dialogue.
350        let ours = TcMessage::decode(&ud.data)
351            .ok()
352            .and_then(|m| m.otid.clone().or(m.dtid.clone()));
353        let dialogue = ours.and_then(|tid| {
354            if tid.len() == 4 {
355                let id = u32::from_be_bytes([tid[0], tid[1], tid[2], tid[3]]);
356                self.dialogues.remove(&id)
357            } else {
358                None
359            }
360        });
361        match dialogue {
362            Some(d) => Inbound::Event(Ss7Event {
363                family: d.family,
364                dialogue_id: d.id,
365                kind: TcKind::Abort,
366                data: json!({
367                    "dialogue_id": d.id,
368                    "tcap": "sccp_returned",
369                    "sccp_return_cause": cause,
370                    "note": "SCCP returned NetGet's message as undeliverable; the dialogue is closed",
371                    "route": d.route.to_json(),
372                }),
373                replies: vec![],
374                ended: true,
375                depth: d.depth,
376            }),
377            None => Inbound::Handled {
378                replies: vec![],
379                note: format!(
380                    "{} cause 0x{cause:02x} for no open dialogue; ignored",
381                    ud.type_name()
382                ),
383            },
384        }
385    }
386
387    fn on_begin(&mut self, m: TcMessage, route: Route, now: Instant) -> Inbound {
388        let otid = m.otid.clone().unwrap_or_default();
389        if self.dialogues.len() >= self.max_dialogues {
390            return match send_tcap(
391                &route,
392                &TcMessage::p_abort(&otid, tcap::P_ABORT_RESOURCE_LIMITATION),
393                "TC-P-ABORT resourceLimitation".into(),
394            ) {
395                Ok(o) => Inbound::Handled {
396                    replies: vec![o],
397                    note: format!(
398                        "decision=refused_dialogue_cap: {} dialogues already open",
399                        self.dialogues.len()
400                    ),
401                },
402                Err(e) => Inbound::Handled {
403                    replies: vec![],
404                    note: e,
405                },
406            };
407        }
408        let acn = m
409            .dialogue
410            .as_ref()
411            .and_then(|d| d.acn().map(|a| a.to_vec()));
412        let family = match &acn {
413            Some(a) if map::context_of(a).is_some() => Some(Family::Map),
414            Some(a) if cap::context_of(a).is_some() => Some(Family::Cap),
415            Some(_) => None,
416            // No dialogue portion is MAP v1 (CAP always has one); its operations are checked one
417            // by one below, and one NetGet does not implement is answered with a Reject.
418            None => Some(Family::Map),
419        };
420        let Some(family) = family else {
421            // An application context NetGet does not implement: refuse it the way TCAP users do,
422            // with an AARE carrying application-context-name-not-supported in a TC-U-ABORT.
423            let reply = TcMessage {
424                kind: TcKind::Abort,
425                otid: None,
426                dtid: Some(otid),
427                dialogue: acn.clone().map(|a| DialoguePdu::Response {
428                    acn: a,
429                    result: 1,
430                    diagnostic_provider: false,
431                    diagnostic: tcap::DIAG_USER_ACN_NOT_SUPPORTED,
432                }),
433                components: vec![],
434                p_abort_cause: None,
435            };
436            let name = acn
437                .as_deref()
438                .map(ber::oid_string)
439                .unwrap_or_else(|| "none".into());
440            return match send_tcap(&route, &reply, "TC-U-ABORT ACN not supported".into()) {
441                Ok(o) => Inbound::Handled {
442                    replies: vec![o],
443                    note: format!(
444                        "decision=refused_application_context: {name} is neither a MAP context \
445                         nor a CAP gsmSSF-to-gsmSCF context"
446                    ),
447                },
448                Err(e) => Inbound::Handled {
449                    replies: vec![],
450                    note: e,
451                },
452            };
453        };
454
455        let id = self.allocate();
456        let mut d = Dialogue {
457            id,
458            remote_tid: Some(otid),
459            acn: acn.clone(),
460            family,
461            aare_pending: matches!(m.dialogue, Some(DialoguePdu::Request { .. })),
462            route,
463            last_activity: now,
464            peer_invokes: vec![],
465            own_invokes: vec![],
466            next_invoke_id: 1,
467            awaiting_answer: false,
468            depth: 0,
469        };
470        let (components, rejects) = describe_components(&mut d, &m.components);
471        if !rejects.is_empty() {
472            // Rust answers an operation it cannot decode: TC-END with the AARE and a Reject per
473            // bad invoke. The model is not consulted about a message it could not read.
474            let reply = TcMessage {
475                kind: TcKind::End,
476                otid: None,
477                dtid: d.remote_tid.clone(),
478                dialogue: d.acn.clone().filter(|_| d.aare_pending).map(accept),
479                components: rejects,
480                p_abort_cause: None,
481            };
482            return match send_tcap(&d.route, &reply, "TC-END with Reject".into()) {
483                Ok(o) => Inbound::Handled {
484                    replies: vec![o],
485                    note: "decision=rejected_operation: an invoke NetGet cannot decode was \
486                           answered with Reject"
487                        .into(),
488                },
489                Err(e) => Inbound::Handled {
490                    replies: vec![],
491                    note: e,
492                },
493            };
494        }
495        // activityTest needs no decision.
496        if let Some(r) = self.rust_answers(&mut d) {
497            return r;
498        }
499        d.awaiting_answer = needs_answer(&d, &components);
500        let data = event_data(&d, &m, components);
501        let ev = Ss7Event {
502            family,
503            dialogue_id: id,
504            kind: TcKind::Begin,
505            data,
506            replies: vec![],
507            ended: false,
508            depth: 0,
509        };
510        self.dialogues.insert(id, d);
511        Inbound::Event(ev)
512    }
513
514    fn rust_answers(&mut self, d: &mut Dialogue) -> Option<Inbound> {
515        if d.family == Family::Cap
516            && !d.peer_invokes.is_empty()
517            && d.peer_invokes
518                .iter()
519                .all(|(_, op)| *op == cap::OP_ACTIVITY_TEST)
520        {
521            let comps = d
522                .peer_invokes
523                .drain(..)
524                .map(|(id, _)| Component::ReturnResult {
525                    last: true,
526                    invoke_id: id,
527                    opcode: None,
528                    parameter: None,
529                })
530                .collect();
531            let reply = TcMessage {
532                kind: TcKind::End,
533                otid: None,
534                dtid: d.remote_tid.clone(),
535                dialogue: d.acn.clone().filter(|_| d.aare_pending).map(accept),
536                components: comps,
537                p_abort_cause: None,
538            };
539            self.dialogues.remove(&d.id);
540            return Some(
541                match send_tcap(&d.route, &reply, "activityTest result".into()) {
542                    Ok(o) => Inbound::Handled {
543                        replies: vec![o],
544                        note: "CAP activityTest answered in Rust".into(),
545                    },
546                    Err(e) => Inbound::Handled {
547                        replies: vec![],
548                        note: e,
549                    },
550                },
551            );
552        }
553        None
554    }
555
556    fn on_continue(&mut self, m: TcMessage, route: Route, now: Instant) -> Inbound {
557        let dtid = m.dtid.clone().unwrap_or_default();
558        let otid = m.otid.clone().unwrap_or_default();
559        let Some(d) = self.find_by_local_tid(&dtid) else {
560            return p_abort_unknown(&route, &otid, &dtid);
561        };
562        match &d.remote_tid {
563            // The first answer to a Begin NetGet sent: it names the peer's transaction.
564            None => d.remote_tid = Some(otid.clone()),
565            Some(known) if *known != otid => {
566                let reply = TcMessage::p_abort(&otid, tcap::P_ABORT_INCORRECT_TRANSACTION_PORTION);
567                return match send_tcap(
568                    &route,
569                    &reply,
570                    "TC-P-ABORT incorrectTransactionPortion".into(),
571                ) {
572                    Ok(o) => Inbound::Handled {
573                        replies: vec![o],
574                        note: format!(
575                            "decision=refused_transaction: TC-CONTINUE for dialogue {} from \
576                             transaction {} (expected {})",
577                            d.id,
578                            ber::hex(&otid),
579                            ber::hex(known)
580                        ),
581                    },
582                    Err(e) => Inbound::Handled {
583                        replies: vec![],
584                        note: e,
585                    },
586                };
587            }
588            Some(_) => {}
589        }
590        d.last_activity = now;
591        // Follow the peer's MTP/SCCP addressing from here on (its first Continue may come from a
592        // different point code than the one the Begin was routed to).
593        d.route.remote = route.remote.clone();
594        d.route.dpc = route.dpc;
595        d.route.sls = route.sls;
596        let mut d = d.clone();
597        if let Some(DialoguePdu::Response { result, .. }) = &m.dialogue {
598            if *result != 0 {
599                self.dialogues.remove(&d.id);
600                return Inbound::Event(Ss7Event {
601                    family: d.family,
602                    dialogue_id: d.id,
603                    kind: TcKind::Continue,
604                    data: event_data(&d, &m, vec![]),
605                    replies: vec![],
606                    ended: true,
607                    depth: d.depth,
608                });
609            }
610        }
611        let (components, rejects) = describe_components(&mut d, &m.components);
612        if !rejects.is_empty() {
613            let reply = TcMessage {
614                kind: TcKind::Continue,
615                otid: Some(d.tid()),
616                dtid: d.remote_tid.clone(),
617                dialogue: None,
618                components: rejects,
619                p_abort_cause: None,
620            };
621            if let Ok(o) = send_tcap(&d.route, &reply, "TC-CONTINUE with Reject".into()) {
622                self.dialogues.insert(d.id, d.clone());
623                return Inbound::Handled {
624                    replies: vec![o],
625                    note: "decision=rejected_operation: an invoke NetGet cannot decode was \
626                           answered with Reject"
627                        .into(),
628                };
629            }
630        }
631        if let Some(r) = self.rust_answers(&mut d) {
632            return r;
633        }
634        d.awaiting_answer = needs_answer(&d, &components);
635        let data = event_data(&d, &m, components);
636        let ev = Ss7Event {
637            family: d.family,
638            dialogue_id: d.id,
639            kind: TcKind::Continue,
640            data,
641            replies: vec![],
642            ended: false,
643            depth: d.depth,
644        };
645        self.dialogues.insert(d.id, d);
646        Inbound::Event(ev)
647    }
648
649    fn on_end_or_abort(&mut self, m: TcMessage, _now: Instant) -> Inbound {
650        let dtid = m.dtid.clone().unwrap_or_default();
651        let id = match self.find_by_local_tid(&dtid) {
652            Some(d) => d.id,
653            None => {
654                // Neither End nor Abort can be answered (Q.774: discard).
655                return Inbound::Handled {
656                    replies: vec![],
657                    note: format!(
658                        "decision=discarded_unknown_transaction: TC-{} for transaction {} which \
659                         no dialogue here owns",
660                        m.kind.name().to_uppercase(),
661                        ber::hex(&dtid)
662                    ),
663                };
664            }
665        };
666        let mut d = self.dialogues.remove(&id).expect("found above");
667        let (components, _) = describe_components(&mut d, &m.components);
668        Inbound::Event(Ss7Event {
669            family: d.family,
670            dialogue_id: d.id,
671            kind: m.kind,
672            data: event_data(&d, &m, components),
673            replies: vec![],
674            ended: true,
675            depth: d.depth,
676        })
677    }
678
679    /// Whether the model still owes `id` an answer.
680    pub fn awaiting_answer(&self, id: u32) -> bool {
681        self.dialogues
682            .get(&id)
683            .map(|d| d.awaiting_answer)
684            .unwrap_or(false)
685    }
686
687    /// Close a dialogue the model left unanswered or could not be asked about.
688    ///
689    /// `fail_closed`: MAP answers each pending invoke with ReturnError systemFailure in a TC-END
690    /// (with the AARE, so the error reaches the MAP user rather than a dialogue failure); CAP has
691    /// no such answer for InitialDP that is not a decision, so it is a TC-U-ABORT. Without
692    /// `fail_closed` (the model said nothing) both are a TC-U-ABORT.
693    pub fn close_unanswered(&mut self, id: u32, fail_closed: bool) -> Option<Outgoing> {
694        let d = self.dialogues.remove(&id)?;
695        let reply = if fail_closed && d.family == Family::Map && !d.peer_invokes.is_empty() {
696            TcMessage {
697                kind: TcKind::End,
698                otid: None,
699                dtid: d.remote_tid.clone(),
700                dialogue: d.acn.clone().filter(|_| d.aare_pending).map(accept),
701                components: d
702                    .peer_invokes
703                    .iter()
704                    .map(|(inv, _)| Component::ReturnError {
705                        invoke_id: *inv,
706                        error: Code::Local(map::ERROR_SYSTEM_FAILURE),
707                        parameter: None,
708                    })
709                    .collect(),
710                p_abort_cause: None,
711            }
712        } else {
713            user_abort(&d, tcap::DIAG_USER_NO_REASON_GIVEN)
714        };
715        let label = if reply.kind == TcKind::End {
716            "TC-END ReturnError systemFailure"
717        } else {
718            "TC-U-ABORT"
719        };
720        send_tcap(&d.route, &reply, label.into()).ok()
721    }
722
723    /// Abort and forget dialogues idle past [`DIALOGUE_IDLE_TIMEOUT`].
724    pub fn expire(&mut self, now: Instant) -> Vec<(Dialogue, Option<Outgoing>)> {
725        let stale: Vec<u32> = self
726            .dialogues
727            .values()
728            .filter(|d| now.saturating_duration_since(d.last_activity) >= DIALOGUE_IDLE_TIMEOUT)
729            .map(|d| d.id)
730            .collect();
731        stale
732            .into_iter()
733            .filter_map(|id| self.dialogues.remove(&id))
734            .map(|d| {
735                let out = d.remote_tid.as_ref().and_then(|_| {
736                    send_tcap(
737                        &d.route,
738                        &user_abort(&d, tcap::DIAG_USER_NO_REASON_GIVEN),
739                        "TC-U-ABORT idle".into(),
740                    )
741                    .ok()
742                });
743                (d, out)
744            })
745            .collect()
746    }
747
748    /// The soonest moment a dialogue will expire, if any are open.
749    pub fn next_expiry(&self) -> Option<Instant> {
750        self.dialogues
751            .values()
752            .map(|d| d.last_activity + DIALOGUE_IDLE_TIMEOUT)
753            .min()
754    }
755
756    /// Turn the model's intent into TCAP for its dialogue. `default_route` is used when the intent
757    /// opens a new dialogue (`send_map_request` / `send_cap_operations` without a `dialogue_id`).
758    pub fn apply(
759        &mut self,
760        intent: &Value,
761        default_route: Option<&Route>,
762        depth: u32,
763        now: Instant,
764    ) -> Result<(u32, Outgoing), String> {
765        let kind = intent["type"].as_str().unwrap_or_default();
766        let dialogue_id = match intent.get("dialogue_id") {
767            None | Some(Value::Null) => None,
768            Some(v) => Some(
769                v.as_u64()
770                    .filter(|n| *n <= u32::MAX as u64)
771                    .ok_or("dialogue_id must be the integer from the event")?
772                    as u32,
773            ),
774        };
775        match kind {
776            "send_map_request" | "send_cap_operations" if dialogue_id.is_none() => {
777                self.originate(kind, intent, default_route, depth, now)
778            }
779            _ => {
780                let id = dialogue_id.ok_or_else(|| format!("{kind} needs a dialogue_id"))?;
781                let mut d = self.dialogues.remove(&id).ok_or_else(|| {
782                    format!("dialogue {id} is not open (it ended or never existed)")
783                })?;
784                let result = continue_dialogue(&mut d, kind, intent, depth, now);
785                match result {
786                    Ok((out, keep)) => {
787                        if keep {
788                            self.dialogues.insert(id, d);
789                        }
790                        Ok((id, out))
791                    }
792                    Err(e) => {
793                        self.dialogues.insert(id, d);
794                        Err(e)
795                    }
796                }
797            }
798        }
799    }
800
801    fn originate(
802        &mut self,
803        kind: &str,
804        intent: &Value,
805        default_route: Option<&Route>,
806        depth: u32,
807        now: Instant,
808    ) -> Result<(u32, Outgoing), String> {
809        if self.dialogues.len() >= self.max_dialogues {
810            return Err(format!(
811                "{} dialogues are already open; NetGet opens no more",
812                self.dialogues.len()
813            ));
814        }
815        let mut route = default_route
816            .cloned()
817            .ok_or("no route: this association has no peer address to open a dialogue towards")?;
818        if let Some(called) = intent.get("called_address").filter(|v| !v.is_null()) {
819            route.remote = SccpAddress::from_json(called, "called_address")?;
820        }
821        let mut user_information = Vec::new();
822        let (family, acn, components) = if kind == "send_map_request" {
823            let op = intent["operation"]
824                .as_str()
825                .and_then(map::operation_by_name)
826                .ok_or_else(|| format!("operation must be one of {}", map::operation_names()))?;
827            let version = intent
828                .get("context_version")
829                .and_then(Value::as_u64)
830                .unwrap_or(op.version);
831            let argument = intent.get("argument").unwrap_or(&Value::Null);
832            let arg = map::encode_argument(op.code, argument)?;
833            // MAP-OpenInfo. networkUnstructuredSsContext requires it (TS 29.002 section 7.3: the
834            // USSD dialogue names the subscriber and the originator), so for USSD the references
835            // default to the MSISDN and this end's global title.
836            let reference = |field: &str| -> Result<Option<gsm::Address>, String> {
837                intent
838                    .get(field)
839                    .filter(|v| !v.is_null())
840                    .map(|v| gsm::Address::from_json(v, field))
841                    .transpose()
842            };
843            let mut destination = reference("destination_reference")?;
844            let mut origination = reference("origination_reference")?;
845            if op.context == 19 {
846                if destination.is_none() {
847                    destination = argument
848                        .get("msisdn")
849                        .filter(|v| !v.is_null())
850                        .map(|v| gsm::Address::from_json(v, "msisdn"))
851                        .transpose()?;
852                }
853                if origination.is_none() {
854                    origination = route
855                        .local
856                        .global_title
857                        .as_ref()
858                        .map(|g| gsm::Address::international(&g.digits));
859                }
860            }
861            if destination.is_some() || origination.is_some() {
862                user_information.push(tcap::map_open_user_information(
863                    destination.as_ref(),
864                    origination.as_ref(),
865                )?);
866            }
867            (
868                Family::Map,
869                map::acn(op.context, version),
870                vec![(op.code, Some(arg))],
871            )
872        } else {
873            let phase = intent.get("cap_phase").and_then(Value::as_u64).unwrap_or(2);
874            let acn = cap::context_for_phase(phase)
875                .ok_or("cap_phase must be 2, 3 or 4")?
876                .to_vec();
877            (Family::Cap, acn, cap_invokes(intent)?)
878        };
879        let id = self.allocate();
880        let mut d = Dialogue {
881            id,
882            remote_tid: None,
883            acn: Some(acn.clone()),
884            family,
885            aare_pending: false,
886            route,
887            last_activity: now,
888            peer_invokes: vec![],
889            own_invokes: vec![],
890            next_invoke_id: 1,
891            awaiting_answer: false,
892            depth,
893        };
894        let comps = invokes(&mut d, components)?;
895        let m = TcMessage {
896            kind: TcKind::Begin,
897            otid: Some(d.tid()),
898            dtid: None,
899            dialogue: Some(DialoguePdu::Request {
900                acn,
901                user_information,
902            }),
903            components: comps,
904            p_abort_cause: None,
905        };
906        let out = send_tcap(
907            &d.route,
908            &m,
909            format!("TC-BEGIN {} dialogue {id}", family.name()),
910        )?;
911        self.dialogues.insert(id, d);
912        Ok((id, out))
913    }
914}
915
916fn accept(acn: Vec<u64>) -> DialoguePdu {
917    DialoguePdu::Response {
918        acn,
919        result: 0,
920        diagnostic_provider: false,
921        diagnostic: tcap::DIAG_USER_NULL,
922    }
923}
924
925fn user_abort(d: &Dialogue, diagnostic: u8) -> TcMessage {
926    // Before the AARE has gone out, a refusal is an AARE with result reject-permanent;
927    // afterwards it is an ABRT from the dialogue-service-user.
928    let dialogue = match (&d.acn, d.aare_pending) {
929        (Some(acn), true) => Some(DialoguePdu::Response {
930            acn: acn.clone(),
931            result: 1,
932            diagnostic_provider: false,
933            diagnostic,
934        }),
935        (Some(_), false) => Some(DialoguePdu::Abort {
936            source: 0,
937            user_information: vec![],
938        }),
939        (None, _) => None,
940    };
941    TcMessage {
942        kind: TcKind::Abort,
943        otid: None,
944        dtid: d.remote_tid.clone(),
945        dialogue,
946        components: vec![],
947        // MAP v1 has no dialogue portion: its user abort is an Abort carrying no reason at all,
948        // which Q.774 reads as TC-U-ABORT.
949        p_abort_cause: None,
950    }
951}
952
953fn p_abort_unknown(route: &Route, otid: &[u8], dtid: &[u8]) -> Inbound {
954    let reply = TcMessage::p_abort(otid, tcap::P_ABORT_UNRECOGNIZED_TRANSACTION_ID);
955    match send_tcap(route, &reply, "TC-P-ABORT unrecognizedTransactionID".into()) {
956        Ok(o) => Inbound::Handled {
957            replies: vec![o],
958            note: format!(
959                "decision=refused_transaction: TC-CONTINUE for transaction {} which no dialogue \
960                 here owns",
961                ber::hex(dtid)
962            ),
963        },
964        Err(e) => Inbound::Handled {
965            replies: vec![],
966            note: e,
967        },
968    }
969}
970
971fn refuse_undecodable(data: &[u8], route: &Route, cause: u8, e: ber::BerError) -> Inbound {
972    // Find the originating transaction id leniently so the peer's transaction is told.
973    let otid = ber::shallow_children(data).and_then(|kids| {
974        kids.into_iter()
975            .find(|(t, _)| t.same(ber::CLASS_APPLICATION, 8))
976            .map(|(_, v)| v.to_vec())
977            .filter(|v| (1..=4).contains(&v.len()))
978    });
979    let note = format!(
980        "decision=refused_malformed: TCAP did not decode ({e}); P-Abort cause {}",
981        tcap::p_abort_name(cause)
982    );
983    match otid {
984        Some(otid) => match send_tcap(
985            route,
986            &TcMessage::p_abort(&otid, cause),
987            format!("TC-P-ABORT {}", tcap::p_abort_name(cause)),
988        ) {
989            Ok(o) => Inbound::Handled {
990                replies: vec![o],
991                note,
992            },
993            Err(e) => Inbound::Handled {
994                replies: vec![],
995                note: e,
996            },
997        },
998        None => Inbound::Handled {
999            replies: vec![],
1000            note: format!("{note}; no transaction id to answer, discarded"),
1001        },
1002    }
1003}
1004
1005fn on_scmg(ud: &Unitdata, route: &Route) -> Inbound {
1006    let m = match Scmg::decode(&ud.data) {
1007        Ok(m) => m,
1008        Err(e) => {
1009            return Inbound::Handled {
1010                replies: vec![],
1011                note: format!("SCMG message discarded: {e}"),
1012            }
1013        }
1014    };
1015    if m.format != sccp::SCMG_SST {
1016        return Inbound::Handled {
1017            replies: vec![],
1018            note: format!(
1019                "SCMG {} for SSN {} at PC {} noted",
1020                m.name(),
1021                m.affected_ssn,
1022                m.affected_pc
1023            ),
1024        };
1025    }
1026    // Subsystem Status Test: every subsystem here is answered by the model, so each is allowed.
1027    let ssa = Scmg {
1028        format: sccp::SCMG_SSA,
1029        ..m
1030    };
1031    let mut scmg_route = route.clone();
1032    scmg_route.message_type = sccp::MSG_UDT;
1033    scmg_route.protocol_class = 0;
1034    match sccp::encode_udt(0, false, &ud.calling, &ud.called, &ssa.encode()) {
1035        Ok(b) => Inbound::Handled {
1036            replies: vec![wrap(&scmg_route, b, "SCMG SSA".into())],
1037            note: format!("SCMG SST for SSN {} answered SSA in Rust", m.affected_ssn),
1038        },
1039        Err(e) => Inbound::Handled {
1040            replies: vec![],
1041            note: e,
1042        },
1043    }
1044}
1045
1046/// Describe each component for the model, recording the peer's invokes on the dialogue and
1047/// matching results to NetGet's own. Returns (descriptions, Reject components to send).
1048fn describe_components(d: &mut Dialogue, comps: &[Component]) -> (Vec<Value>, Vec<Component>) {
1049    let mut out = Vec::new();
1050    let mut rejects = Vec::new();
1051    for c in comps {
1052        match c {
1053            Component::Invoke {
1054                invoke_id,
1055                linked_id,
1056                opcode,
1057                parameter,
1058            } => {
1059                let code = opcode.local();
1060                let decoded = match (d.family, code) {
1061                    (Family::Map, Some(code)) => match map::operation_by_code(code) {
1062                        Some(op) => map::decode_argument(code, parameter.as_ref())
1063                            .map(|a| (op.name.to_string(), a)),
1064                        None => Err(format!("MAP operation {code} is not implemented")),
1065                    },
1066                    (Family::Cap, Some(code)) => match cap::operation_name(code) {
1067                        Some(name) => cap::decode_argument(code, parameter.as_ref())
1068                            .map(|a| (name.to_string(), a)),
1069                        None => Err(format!("CAP operation {code} is not implemented")),
1070                    },
1071                    (_, None) => Err("global operation codes are not implemented".into()),
1072                };
1073                match decoded {
1074                    Ok((name, argument)) => {
1075                        d.peer_invokes.push((*invoke_id, code.unwrap_or(-1)));
1076                        out.push(json!({
1077                            "component": "invoke",
1078                            "invoke_id": invoke_id,
1079                            "linked_id": linked_id,
1080                            "operation": name,
1081                            "opcode": code,
1082                            "argument": argument,
1083                        }));
1084                    }
1085                    Err(e) => {
1086                        let unknown = e.contains("not implemented");
1087                        rejects.push(Component::Reject {
1088                            invoke_id: Some(*invoke_id),
1089                            problem_type: tcap::PROBLEM_INVOKE,
1090                            problem: if unknown {
1091                                tcap::INVOKE_UNRECOGNIZED_OPERATION
1092                            } else {
1093                                tcap::INVOKE_MISTYPED_PARAMETER
1094                            },
1095                        });
1096                        out.push(json!({
1097                            "component": "invoke",
1098                            "invoke_id": invoke_id,
1099                            "opcode": code,
1100                            "rejected": e,
1101                        }));
1102                    }
1103                }
1104            }
1105            Component::ReturnResult {
1106                last,
1107                invoke_id,
1108                opcode,
1109                parameter,
1110            } => {
1111                let own = d.own_invokes.iter().position(|(i, _)| i == invoke_id);
1112                let code = own
1113                    .map(|p| d.own_invokes[p].1)
1114                    .or_else(|| opcode.as_ref().and_then(Code::local));
1115                if *last {
1116                    if let Some(p) = own {
1117                        d.own_invokes.remove(p);
1118                    }
1119                }
1120                let result = match (d.family, code) {
1121                    (Family::Map, Some(code)) => map::decode_result(code, parameter.as_ref())
1122                        .unwrap_or_else(|e| json!({ "error": e })),
1123                    _ => json!({}),
1124                };
1125                out.push(json!({
1126                    "component": c.kind_name(),
1127                    "invoke_id": invoke_id,
1128                    "matched_own_invoke": own.is_some(),
1129                    "operation": code.and_then(|c| op_name(d.family, c)),
1130                    "result": result,
1131                }));
1132            }
1133            Component::ReturnError {
1134                invoke_id,
1135                error,
1136                parameter,
1137            } => {
1138                let own = d.own_invokes.iter().position(|(i, _)| i == invoke_id);
1139                let op = own.map(|p| d.own_invokes.remove(p).1);
1140                let code = error.local().unwrap_or(-1);
1141                out.push(json!({
1142                    "component": "return_error",
1143                    "invoke_id": invoke_id,
1144                    "matched_own_invoke": op.is_some(),
1145                    "operation": op.and_then(|c| op_name(d.family, c)),
1146                    "error": match d.family {
1147                        Family::Map => map::error_name(code),
1148                        Family::Cap => cap::error_name(code),
1149                    },
1150                    "error_code": code,
1151                    "parameter": match d.family {
1152                        Family::Map => map::decode_error_parameter(code, parameter.as_ref()),
1153                        Family::Cap => Value::Null,
1154                    },
1155                }));
1156            }
1157            Component::Reject {
1158                invoke_id,
1159                problem_type,
1160                problem,
1161            } => {
1162                if let Some(i) = invoke_id {
1163                    d.own_invokes.retain(|(x, _)| x != i);
1164                }
1165                out.push(json!({
1166                    "component": "reject",
1167                    "invoke_id": invoke_id,
1168                    "problem": tcap::reject_problem_name(*problem_type, *problem),
1169                }));
1170            }
1171        }
1172    }
1173    (out, rejects)
1174}
1175
1176fn op_name(family: Family, code: i64) -> Option<String> {
1177    match family {
1178        Family::Map => map::operation_by_code(code).map(|o| o.name.to_string()),
1179        Family::Cap => cap::operation_name(code).map(str::to_string),
1180    }
1181}
1182
1183fn needs_answer(d: &Dialogue, components: &[Value]) -> bool {
1184    match d.family {
1185        Family::Map => !d.peer_invokes.is_empty(),
1186        Family::Cap => components.iter().any(|c| {
1187            c["component"] == "invoke"
1188                && (c["opcode"] == cap::OP_INITIAL_DP
1189                    || (c["opcode"] == cap::OP_EVENT_REPORT_BCSM
1190                        && c["argument"]["message_type"] == "request"))
1191        }),
1192    }
1193}
1194
1195fn event_data(d: &Dialogue, m: &TcMessage, components: Vec<Value>) -> Value {
1196    let context = d.acn.as_ref().map(|a| {
1197        let (name, version) = map::context_of(a)
1198            .or_else(|| cap::context_of(a))
1199            .unwrap_or(("unknown", 0));
1200        json!({"name": name, "version": version, "oid": ber::oid_string(a)})
1201    });
1202    let mut v = json!({
1203        "dialogue_id": d.id,
1204        "tcap": m.kind.name(),
1205        "family": d.family.name(),
1206        "application_context": context,
1207        "dialogue_portion": m.dialogue.as_ref().map(DialoguePdu::to_json),
1208        "components": components,
1209        "awaiting_answer_to": d.peer_invokes.iter().map(|(i, _)| *i).collect::<Vec<_>>(),
1210        "route": d.route.to_json(),
1211        "peer_transaction_id": d.remote_tid.as_deref().map(ber::hex),
1212    });
1213    if m.kind == TcKind::Abort {
1214        v["abort"] = json!({
1215            "p_abort_cause": m.p_abort_cause.map(tcap::p_abort_name),
1216            "user_abort": m.dialogue.as_ref().map(DialoguePdu::to_json),
1217        });
1218    }
1219    v
1220}
1221
1222/// Encode a list of invokes on `d`, recording them as NetGet's own.
1223fn invokes(d: &mut Dialogue, list: Vec<(i64, Option<Vec<u8>>)>) -> Result<Vec<Component>, String> {
1224    let mut out = Vec::new();
1225    for (code, arg) in list {
1226        let id = d.take_invoke_id();
1227        let parameter = arg
1228            .map(|b| ber::decode(&b).map_err(|e| e.to_string()))
1229            .transpose()?;
1230        d.own_invokes.push((id, code));
1231        out.push(Component::Invoke {
1232            invoke_id: id,
1233            linked_id: None,
1234            opcode: Code::Local(code),
1235            parameter,
1236        });
1237    }
1238    Ok(out)
1239}
1240
1241/// (operation code, encoded argument) pairs.
1242type Invokes = Vec<(i64, Option<Vec<u8>>)>;
1243
1244fn cap_invokes(intent: &Value) -> Result<Invokes, String> {
1245    let ops = intent
1246        .get("operations")
1247        .and_then(Value::as_array)
1248        .ok_or("operations is required (a list)")?;
1249    if ops.is_empty() || ops.len() > tcap::MAX_COMPONENTS {
1250        return Err(format!(
1251            "operations must hold 1..{} entries",
1252            tcap::MAX_COMPONENTS
1253        ));
1254    }
1255    ops.iter()
1256        .enumerate()
1257        .map(|(i, op)| {
1258            let name = op["operation"]
1259                .as_str()
1260                .ok_or_else(|| format!("operations[{i}].operation is required"))?;
1261            let code = cap::operation_code(name).ok_or_else(|| {
1262                format!(
1263                    "operations[{i}].operation {name:?} is not one of {}",
1264                    cap::OPERATIONS
1265                        .iter()
1266                        .map(|(n, _)| *n)
1267                        .collect::<Vec<_>>()
1268                        .join(", ")
1269                )
1270            })?;
1271            Ok((code, cap::encode_argument(code, op)?))
1272        })
1273        .collect()
1274}
1275
1276/// Encode the model's intent for an open dialogue. Returns the message and whether the dialogue
1277/// stays open.
1278fn continue_dialogue(
1279    d: &mut Dialogue,
1280    kind: &str,
1281    intent: &Value,
1282    depth: u32,
1283    now: Instant,
1284) -> Result<(Outgoing, bool), String> {
1285    let pick_invoke = |d: &Dialogue| -> Result<(i64, i64), String> {
1286        match intent.get("invoke_id").and_then(Value::as_i64) {
1287            Some(i) => d
1288                .peer_invokes
1289                .iter()
1290                .find(|(x, _)| *x == i)
1291                .copied()
1292                .ok_or_else(|| {
1293                    format!("invoke {i} in dialogue {} is not awaiting an answer", d.id)
1294                }),
1295            None => d
1296                .peer_invokes
1297                .first()
1298                .copied()
1299                .ok_or_else(|| format!("dialogue {} has no invoke awaiting an answer", d.id)),
1300        }
1301    };
1302    let end_default;
1303    let mut components = Vec::new();
1304    let mut message_kind = None;
1305    match kind {
1306        "send_map_result" => {
1307            if d.family != Family::Map {
1308                return Err(format!("dialogue {} is CAP, not MAP", d.id));
1309            }
1310            let (inv, code) = pick_invoke(d)?;
1311            let result = map::encode_result(code, intent.get("result").unwrap_or(&json!({})))?;
1312            let parameter = result
1313                .map(|b| ber::decode(&b).map_err(|e| e.to_string()))
1314                .transpose()?;
1315            d.peer_invokes.retain(|(x, _)| *x != inv);
1316            components.push(Component::ReturnResult {
1317                last: true,
1318                invoke_id: inv,
1319                opcode: parameter.as_ref().map(|_| Code::Local(code)),
1320                parameter,
1321            });
1322            end_default = d.peer_invokes.is_empty();
1323        }
1324        "send_map_error" => {
1325            let (inv, _) = pick_invoke(d)?;
1326            let code = match d.family {
1327                Family::Map => map::error_code(intent.get("error").unwrap_or(&Value::Null))?,
1328                Family::Cap => {
1329                    let e = intent.get("error").unwrap_or(&Value::Null);
1330                    e.as_i64()
1331                        .or_else(|| {
1332                            cap::CAP_ERRORS
1333                                .iter()
1334                                .find(|(n, _)| Some(*n) == e.as_str())
1335                                .map(|(_, c)| *c)
1336                        })
1337                        .ok_or("error must be a CAP error name or code")?
1338                }
1339            };
1340            let parameter = match d.family {
1341                Family::Map => map::encode_error_parameter(code, intent)?
1342                    .map(|b| ber::decode(&b).map_err(|e| e.to_string()))
1343                    .transpose()?,
1344                Family::Cap => None,
1345            };
1346            d.peer_invokes.retain(|(x, _)| *x != inv);
1347            components.push(Component::ReturnError {
1348                invoke_id: inv,
1349                error: Code::Local(code),
1350                parameter,
1351            });
1352            end_default = d.peer_invokes.is_empty();
1353        }
1354        "send_map_request" => {
1355            if d.family != Family::Map {
1356                return Err(format!("dialogue {} is CAP, not MAP", d.id));
1357            }
1358            let op = intent["operation"]
1359                .as_str()
1360                .and_then(map::operation_by_name)
1361                .ok_or_else(|| format!("operation must be one of {}", map::operation_names()))?;
1362            let arg =
1363                map::encode_argument(op.code, intent.get("argument").unwrap_or(&Value::Null))?;
1364            components = invokes(d, vec![(op.code, Some(arg))])?;
1365            end_default = false;
1366        }
1367        "send_cap_operations" => {
1368            if d.family != Family::Cap {
1369                return Err(format!("dialogue {} is MAP, not CAP", d.id));
1370            }
1371            let list = cap_invokes(intent)?;
1372            // Arming detection points means the SCF expects to hear back on this dialogue.
1373            let arms = list
1374                .iter()
1375                .any(|(c, _)| *c == cap::OP_REQUEST_REPORT_BCSM_EVENT);
1376            components = invokes(d, list)?;
1377            // CAP invokes (Connect, Continue, ReleaseCall, RRBCSM) are class 2/4: nothing comes
1378            // back for them, so they are not awaited.
1379            d.own_invokes.clear();
1380            d.peer_invokes.clear();
1381            end_default = !arms;
1382        }
1383        "send_tcap_end" => {
1384            message_kind = Some(TcKind::End);
1385            end_default = true;
1386        }
1387        "send_tcap_abort" => {
1388            let diag = match intent.get("reason").and_then(Value::as_str) {
1389                Some("application_context_not_supported") => tcap::DIAG_USER_ACN_NOT_SUPPORTED,
1390                _ => tcap::DIAG_USER_NO_REASON_GIVEN,
1391            };
1392            let m = user_abort(d, diag);
1393            let out = send_tcap(&d.route, &m, format!("TC-U-ABORT dialogue {}", d.id))?;
1394            return Ok((out, false));
1395        }
1396        other => return Err(format!("{other} is not an SS7 dialogue action")),
1397    }
1398    let end = intent
1399        .get("end_dialogue")
1400        .and_then(Value::as_bool)
1401        .unwrap_or(end_default);
1402    let Some(remote) = d.remote_tid.clone() else {
1403        return Err(format!(
1404            "dialogue {} has not been answered by the peer yet; wait for its TC-CONTINUE",
1405            d.id
1406        ));
1407    };
1408    let kind = message_kind.unwrap_or(if end { TcKind::End } else { TcKind::Continue });
1409    let m = TcMessage {
1410        kind,
1411        otid: if kind == TcKind::Continue {
1412            Some(d.tid())
1413        } else {
1414            None
1415        },
1416        dtid: Some(remote),
1417        dialogue: d.acn.clone().filter(|_| d.aare_pending).map(accept),
1418        components,
1419        p_abort_cause: None,
1420    };
1421    let out = send_tcap(
1422        &d.route,
1423        &m,
1424        format!("TC-{} dialogue {}", kind.name().to_uppercase(), d.id),
1425    )?;
1426    d.aare_pending = false;
1427    d.awaiting_answer = false;
1428    d.last_activity = now;
1429    d.depth = depth;
1430    Ok((out, kind == TcKind::Continue))
1431}
1432
1433/// The action names the engine accepts, for routing `ActionResult::Custom` intents.
1434pub const INTENTS: &[&str] = &[
1435    "send_map_request",
1436    "send_map_result",
1437    "send_map_error",
1438    "send_cap_operations",
1439    "send_tcap_end",
1440    "send_tcap_abort",
1441];
1442
1443/// Validate an intent's static shape without a dialogue (for `execute_action`): every field the
1444/// encoders need is checked now so a malformed action fails where the model can see it.
1445pub fn check_intent(intent: &Value) -> Result<(), String> {
1446    let kind = intent["type"].as_str().unwrap_or_default();
1447    let needs_dialogue = !matches!(kind, "send_map_request" | "send_cap_operations");
1448    if needs_dialogue
1449        && !intent
1450            .get("dialogue_id")
1451            .map(|v| v.is_u64())
1452            .unwrap_or(false)
1453    {
1454        return Err(format!("{kind} needs the dialogue_id from the event"));
1455    }
1456    match kind {
1457        "send_map_request" => {
1458            let op = intent["operation"]
1459                .as_str()
1460                .and_then(map::operation_by_name)
1461                .ok_or_else(|| format!("operation must be one of {}", map::operation_names()))?;
1462            map::encode_argument(op.code, intent.get("argument").unwrap_or(&Value::Null))?;
1463        }
1464        "send_map_result" => {
1465            if let Some(op) = intent
1466                .get("operation")
1467                .and_then(Value::as_str)
1468                .and_then(map::operation_by_name)
1469            {
1470                map::encode_result(op.code, intent.get("result").unwrap_or(&json!({})))?;
1471            }
1472        }
1473        "send_map_error" => {
1474            if intent.get("error").is_none() {
1475                return Err(
1476                    "error is required (a MAP error name such as unknownSubscriber)".into(),
1477                );
1478            }
1479        }
1480        "send_cap_operations" => {
1481            cap_invokes(intent)?;
1482        }
1483        "send_tcap_end" | "send_tcap_abort" => {}
1484        other => return Err(format!("{other} is not an SS7 dialogue action")),
1485    }
1486    Ok(())
1487}