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}