Skip to main content

netget/server/smpp/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct SmppProtocol;
14impl SmppProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template =
36        match name {
37            "smpp_accept" => LogTemplate::new()
38                .with_info("-> SMPP accept message_id={message_id} receipt={receipt}"),
39            "smpp_reject" | "smpp_bind_reject" => {
40                LogTemplate::new().with_info(format!("-> SMPP {name} {{status}}"))
41            }
42            "smpp_submit" => LogTemplate::new()
43                .with_info("-> SMPP submit to {destination_addr}: {preview(text,80)}"),
44            _ => LogTemplate::new().with_info(format!("-> SMPP {name}")),
45        };
46    ActionDefinition {
47        name: name.into(),
48        description: description.into(),
49        parameters,
50        example,
51        log_template: Some(log_template),
52    }
53}
54
55fn bind_accept_action() -> ActionDefinition {
56    action(
57        "smpp_bind_accept",
58        "Accept the bind; the ESME may then submit (and receive, when bound as receiver or transceiver).",
59        vec![],
60        json!({"type":"smpp_bind_accept"}),
61    )
62}
63
64fn bind_reject_action() -> ActionDefinition {
65    action(
66        "smpp_bind_reject",
67        "Refuse the bind with an SMPP status; the connection is then closed.",
68        vec![parameter(
69            "status",
70            "string",
71            "ESME_RINVPASWD, ESME_RINVSYSID or ESME_RBINDFAIL (default)",
72            false,
73        )],
74        json!({"type":"smpp_bind_reject","status":"ESME_RINVPASWD"}),
75    )
76}
77
78fn accept_action() -> ActionDefinition {
79    action(
80        "smpp_accept",
81        "Accept the message. Rust answers submit_sm_resp, then sends a delivery receipt if the ESME asked for one, and an optional mobile-originated reply.",
82        vec![
83            parameter("message_id", "string", "SMSC message id, at most 64 characters; Rust assigns one when absent", false),
84            parameter("receipt", "string", "Final state for the delivery receipt: DELIVRD, UNDELIV, EXPIRED, REJECTD or DELETED; none when absent", false),
85            parameter("reply_text", "string", "Text sent back to the sender as a mobile-originated deliver_sm from the destination", false),
86        ],
87        json!({"type":"smpp_accept","receipt":"DELIVRD"}),
88    )
89}
90
91fn reject_action() -> ActionDefinition {
92    action(
93        "smpp_reject",
94        "Refuse the message with an SMPP error status in submit_sm_resp.",
95        vec![parameter(
96            "status",
97            "string",
98            "ESME_RINVDSTADR, ESME_RINVSRCADR, ESME_RINVMSGLEN, ESME_RTHROTTLED, ESME_RSUBMITFAIL, ESME_RX_T_APPN or ESME_RX_P_APPN",
99            true,
100        )],
101        json!({"type":"smpp_reject","status":"ESME_RINVDSTADR"}),
102    )
103}
104
105pub static BIND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
106    EventType::new(
107        "smpp_bind",
108        "An ESME is binding and no credentials are configured; accept or refuse it.",
109        bind_accept_action().example.clone(),
110    )
111    .with_parameters(vec![
112        parameter(
113            "mode",
114            "string",
115            "transmitter, receiver or transceiver",
116            true,
117        ),
118        parameter("system_id", "string", "The ESME's system_id", true),
119        parameter("password", "string", "The password it sent", true),
120        parameter(
121            "system_type",
122            "string",
123            "Its system_type, often empty",
124            true,
125        ),
126        parameter(
127            "interface_version",
128            "number",
129            "SMPP version it speaks (0x34 = 52 for 3.4)",
130            true,
131        ),
132        parameter("remote_addr", "string", "ESME address and port", true),
133    ])
134    .with_actions(vec![bind_accept_action(), bind_reject_action()])
135});
136
137pub static SUBMIT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
138    EventType::new(
139        "smpp_submit",
140        "A bound ESME submitted a short message; accept it (optionally with a receipt or reply) or reject it.",
141        accept_action().example.clone(),
142    )
143    .with_parameters(vec![
144        parameter("system_id", "string", "The submitting ESME's system_id", true),
145        parameter("source_addr", "string", "The sender's address", true),
146        parameter("destination_addr", "string", "Recipient address", true),
147        parameter("text", "string", "The message text, when its data_coding decodes", false),
148        parameter("data", "string", "The message octets in hex, when they are binary", false),
149        parameter("data_coding", "number", "0 default alphabet, 3 Latin-1, 8 UCS-2, others binary", true),
150        parameter("registered_delivery", "boolean", "Whether the ESME asked for a delivery receipt", true),
151        parameter("esm_class", "number", "esm_class octet (0x40 = UDH present)", true),
152        parameter("remote_addr", "string", "ESME address and port", true),
153    ])
154    .with_actions(vec![accept_action(), reject_action()])
155});
156
157pub fn check_message_id(v: &Value) -> Result<()> {
158    if let Some(id) = v.get("message_id") {
159        let id = id
160            .as_str()
161            .ok_or_else(|| anyhow::anyhow!("message_id must be a string"))?;
162        ensure!(
163            !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_graphic()),
164            "message_id must be 1 to 64 printable ASCII characters"
165        );
166    }
167    Ok(())
168}
169
170impl Protocol for SmppProtocol {
171    fn protocol_name(&self) -> &'static str {
172        "SMPP"
173    }
174    fn stack_name(&self) -> &'static str {
175        "ETH>IP>TCP>SMPP"
176    }
177    fn description(&self) -> &'static str {
178        "SMPP 3.4 SMSC: binds, submit_sm, delivery receipts and mobile-originated replies decided by the handler"
179    }
180    fn keywords(&self) -> Vec<&'static str> {
181        vec!["smpp", "sms", "smsc", "esme", "short message", "submit_sm"]
182    }
183    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
184        vec![]
185    }
186    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
187        vec![
188            bind_accept_action(),
189            bind_reject_action(),
190            accept_action(),
191            reject_action(),
192        ]
193    }
194    fn get_event_types(&self) -> Vec<EventType> {
195        vec![BIND_EVENT.clone(), SUBMIT_EVENT.clone()]
196    }
197    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
198        vec![
199            ParameterDefinition {
200                name: "system_id".into(),
201                type_hint: "string".into(),
202                description: "This SMSC's own system_id, returned in every bind response".into(),
203                required: false,
204                example: json!("SMSC"),
205                default: Some(json!(super::DEFAULT_SYSTEM_ID)),
206            },
207            ParameterDefinition {
208                name: "esme_system_id".into(),
209                type_hint: "string".into(),
210                description: "With password: the only system_id allowed to bind, checked in Rust (no smpp_bind event)".into(),
211                required: false,
212                example: json!("esme1"),
213                default: None,
214            },
215            ParameterDefinition {
216                name: "password".into(),
217                type_hint: "string".into(),
218                description: "With esme_system_id: the password checked in Rust, at most 8 characters".into(),
219                required: false,
220                example: json!("secret"),
221                default: None,
222            },
223            ParameterDefinition {
224                name: "idle_timeout_secs".into(),
225                type_hint: "number".into(),
226                description: "Seconds a session may stay silent; ESMEs keep it alive with enquire_link (1..=86400)".into(),
227                required: false,
228                example: json!(600),
229                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
230            },
231        ]
232    }
233    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
234        use crate::protocol::metadata::*;
235        ProtocolMetadataV2::builder()
236            .state(DevelopmentState::Experimental)
237            .privilege_requirement(PrivilegeRequirement::None)
238            .well_known_port(2775)
239            .implementation("SMPP 3.4 over Tokio TCP, hand-written: bind_transmitter/receiver/transceiver, submit_sm with short_message or message_payload, deliver_sm receipts (Appendix B text plus receipted_message_id and message_state) and MO messages, enquire_link, unbind, generic_nack")
240            .llm_control("Bind decisions (unless credentials are configured), and per message: accept with an id, a delivery receipt state and an optional reply, or reject with a status")
241            .e2e_testing("tests/server/smpp: raw PDUs and bounds; Python smpplib and linxGnu/gosmpp (Go) as independent ESMEs")
242            .notes("Plain TCP, no TLS. Text is decoded for data_coding 0, 1, 3 and 8; other codings reach the handler as hex. Receipts and replies go only to sessions bound as receiver or transceiver, and receipts only when registered_delivery asked. PDUs are capped at 64 KiB of payload; the first PDU must be a bind within the idle timeout. A handler failure on a submit answers ESME_RSYSERR (ESME_RTHROTTLED when the backend is saturated), on a bind ESME_RBINDFAIL; nothing is ever accepted by default.")
243            .request_only("Every response answers the ESME's PDU; receipts and replies follow a submit the handler accepted")
244            .max_inbound_bytes(super::wire::MAX_PDU)
245            .build()
246    }
247    fn example_prompt(&self) -> &'static str {
248        "SMPP SMSC on port 2775 that accepts messages to +1555 numbers with a delivered receipt and rejects the rest"
249    }
250    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
251        let llm = json!({"type":"open_server","base_stack":"smpp","port":2775,"instruction":"Accept messages to numbers starting 1555 with a DELIVRD receipt; reject others with ESME_RINVDSTADR","startup_params":{"esme_system_id":"esme1","password":"secret"}});
252        let mut scripted = llm.clone();
253        scripted["event_handlers"] = json!([{"event_pattern":"smpp_submit","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nok=e['destination_addr'].lstrip('+').startswith('1555')\na={'type':'smpp_accept','receipt':'DELIVRD'} if ok else {'type':'smpp_reject','status':'ESME_RINVDSTADR'}\nprint(json.dumps({'actions':[a]}))"}}]);
254        let mut static_example = llm.clone();
255        static_example["event_handlers"] = json!([{"event_pattern":"smpp_submit","handler":{"type":"static","actions":[{"type":"smpp_accept","receipt":"DELIVRD"}]}}]);
256        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
257    }
258    fn group_name(&self) -> &'static str {
259        "Application"
260    }
261}
262
263impl Server for SmppProtocol {
264    fn spawn(
265        &self,
266        ctx: SpawnContext,
267    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
268    {
269        Box::pin(super::spawn(ctx))
270    }
271    fn execute_action(&self, v: Value) -> Result<ActionResult> {
272        let name = v["type"].as_str().unwrap_or_default().to_string();
273        match name.as_str() {
274            "smpp_bind_accept" => {}
275            "smpp_bind_reject" => {
276                if let Some(s) = v.get("status") {
277                    let s = s
278                        .as_str()
279                        .ok_or_else(|| anyhow::anyhow!("status must be a string"))?;
280                    ensure!(
281                        matches!(s, "ESME_RINVPASWD" | "ESME_RINVSYSID" | "ESME_RBINDFAIL"),
282                        "bind status must be ESME_RINVPASWD, ESME_RINVSYSID or ESME_RBINDFAIL"
283                    );
284                }
285            }
286            "smpp_accept" => {
287                check_message_id(&v)?;
288                if let Some(r) = v.get("receipt") {
289                    ensure!(
290                        matches!(
291                            r.as_str(),
292                            Some("DELIVRD" | "UNDELIV" | "EXPIRED" | "REJECTD" | "DELETED")
293                        ),
294                        "receipt must be DELIVRD, UNDELIV, EXPIRED, REJECTD or DELETED"
295                    );
296                }
297                if let Some(t) = v.get("reply_text") {
298                    let t = t
299                        .as_str()
300                        .ok_or_else(|| anyhow::anyhow!("reply_text must be a string"))?;
301                    ensure!(
302                        super::wire::encode_text(t).1.len() <= super::wire::MAX_PAYLOAD,
303                        "reply_text exceeds 64 KiB"
304                    );
305                }
306            }
307            "smpp_reject" => {
308                let s = v["status"]
309                    .as_str()
310                    .ok_or_else(|| anyhow::anyhow!("status must be a string"))?;
311                ensure!(super::wire::status_code(s).is_some(), "unknown status {s}");
312            }
313            _ => bail!("Unknown SMPP server action"),
314        }
315        Ok(ActionResult::Custom { name, data: v })
316    }
317}