Run python-smpplib and php-smpp against our server: encodings and bind direction hold, no defects
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# No licence declared for this fork (composer.json says LGPL-2.0-or-later, but no top-level
|
||||
# LICENSE file - see findings/06-python-php.md); test-only, cloned at a pinned commit, never
|
||||
# vendored into this repo.
|
||||
FROM php:8.4.25-cli AS source
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG PHPSMPP_COMMIT=1d3b53c2d2b63d51ab70009d956914b7f8903118
|
||||
|
||||
RUN git clone https://github.com/alexandr-mironov/php-smpp.git /src \
|
||||
&& cd /src \
|
||||
&& git checkout "${PHPSMPP_COMMIT}"
|
||||
|
||||
# PHP 8's sockets extension returns a Socket object from socket_create(), not a resource; this
|
||||
# fork's own isOpen() checks is_resource() alone (written pre-PHP8), so it is always false and every
|
||||
# guarded call ("Socket is not open") fails immediately, on any PHP 8.x. Patched here, not in
|
||||
# src/Socket.php - see findings/06-python-php.md.
|
||||
RUN sed -i 's/if (!is_resource(\$this->socket)) {/if (!is_resource(\$this->socket) \&\& !(\$this->socket instanceof \\Socket)) {/' /src/src/transport/Socket.php
|
||||
|
||||
FROM php:8.4.25-cli
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libonig-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& docker-php-ext-install sockets mbstring
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=source /src/src /app/smpp-src
|
||||
COPY driver.php .
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["php", "/app/driver.php"]
|
||||
CMD ["node", "2775"]
|
||||
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// HTTP-driven php-smpp ESME: one connection at a time (php-smpp itself blocks synchronously on
|
||||
// every send and read), holding named client sockets open across requests the way the Java and
|
||||
// Python drivers do (see AGENTS.md). No composer: a tiny PSR-4-shaped autoloader over the fork's
|
||||
// own src/ tree, cloned at a pinned commit during the image build.
|
||||
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$prefix = 'smpp\\';
|
||||
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = '/app/smpp-src/' . str_replace('\\', '/', $relative) . '.php';
|
||||
|
||||
if (is_file($path)) {
|
||||
require $path;
|
||||
}
|
||||
});
|
||||
|
||||
use smpp\Address;
|
||||
use smpp\Client;
|
||||
use smpp\DeliveryReceipt;
|
||||
use smpp\exceptions\SmppException;
|
||||
use smpp\helpers\GsmEncoderHelper;
|
||||
use smpp\SMPP;
|
||||
use smpp\transport\Socket;
|
||||
|
||||
$targetHost = $argv[1] ?? 'node';
|
||||
$targetPort = (int) ($argv[2] ?? 2775);
|
||||
|
||||
/** @var array<string, Client> */
|
||||
$clients = [];
|
||||
|
||||
function jsonBody(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
|
||||
return $raw === '' || $raw === false ? [] : (json_decode($raw, true) ?? []);
|
||||
}
|
||||
|
||||
function respond(int $status, array $payload): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($payload);
|
||||
}
|
||||
|
||||
function encodeBody(string $text, int $dataCoding): string
|
||||
{
|
||||
if ($dataCoding === SMPP::DATA_CODING_DEFAULT) {
|
||||
return GsmEncoderHelper::utf8_to_gsm0338($text);
|
||||
}
|
||||
|
||||
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
|
||||
return mb_convert_encoding($text, 'ISO-8859-1', 'UTF-8');
|
||||
}
|
||||
|
||||
// UCS2: sendSMS() converts internally, so the raw UTF-8 text passes straight through here.
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Inverse of GsmEncoderHelper's dict, for decoding what this driver receives - reusing the peer's
|
||||
// own table so a mismatch against what was sent is the peer's own encode/decode disagreeing with
|
||||
// itself, not this driver guessing at the real GSM 03.38 one.
|
||||
function gsmDecode(string $data): string
|
||||
{
|
||||
static $reverse = null;
|
||||
|
||||
if ($reverse === null) {
|
||||
$reverse = [];
|
||||
|
||||
foreach (gsmEncodeDict() as $char => $bytes) {
|
||||
$reverse[$bytes] = $char;
|
||||
}
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$length = strlen($data);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $length) {
|
||||
if ($data[$i] === "\x1B" && $i + 1 < $length) {
|
||||
$pair = substr($data, $i, 2);
|
||||
$result .= $reverse[$pair] ?? '?';
|
||||
$i += 2;
|
||||
} else {
|
||||
$result .= $reverse[$data[$i]] ?? $data[$i];
|
||||
$i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// The dict inside utf8_to_gsm0338() is a local literal, not a class constant - re-derived once by
|
||||
// encoding every basic-table/extension character singly and reading back what came out, rather
|
||||
// than duplicating the private table here.
|
||||
function gsmEncodeDict(): array
|
||||
{
|
||||
static $dict = null;
|
||||
|
||||
if ($dict !== null) {
|
||||
return $dict;
|
||||
}
|
||||
|
||||
$dict = [];
|
||||
$candidates = ['@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', 'Ø', 'ø', 'Å', 'å', 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', 'Σ', 'Θ', 'Ξ', 'Æ', 'æ', 'ß', 'É', '¡', 'Ä', 'Ö', 'Ñ', 'Ü', '§', '¿', 'ä', 'ö', 'ñ', 'ü', 'à', '^', '{', '}', '\\', '[', '~', ']', '|', '€'];
|
||||
|
||||
foreach ($candidates as $char) {
|
||||
$encoded = GsmEncoderHelper::utf8_to_gsm0338($char);
|
||||
|
||||
if ($encoded !== $char) {
|
||||
$dict[$char] = $encoded;
|
||||
}
|
||||
}
|
||||
|
||||
return $dict;
|
||||
}
|
||||
|
||||
function decodeBody(string $data, int $dataCoding): string
|
||||
{
|
||||
if ($dataCoding === SMPP::DATA_CODING_UCS2) {
|
||||
return mb_convert_encoding($data, 'UTF-8', 'UCS-2BE');
|
||||
}
|
||||
|
||||
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
|
||||
return mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1');
|
||||
}
|
||||
|
||||
return gsmDecode($data);
|
||||
}
|
||||
|
||||
function doBind(array $body): array
|
||||
{
|
||||
global $clients, $targetHost, $targetPort;
|
||||
|
||||
$name = $body['name'];
|
||||
$recvTimeoutMs = (int) ($body['recvTimeoutMs'] ?? 5000);
|
||||
|
||||
$transport = new Socket([$targetHost], $targetPort);
|
||||
$transport->setRecvTimeout($recvTimeoutMs);
|
||||
$transport->open();
|
||||
|
||||
$client = new Client($transport);
|
||||
Client::$smsNullTerminateOctetStrings = false;
|
||||
|
||||
if (isset($body['interfaceVersion'])) {
|
||||
Client::$interfaceVersion = (int) $body['interfaceVersion'];
|
||||
}
|
||||
|
||||
$systemId = $body['systemId'];
|
||||
$password = $body['password'];
|
||||
|
||||
switch ($body['mode']) {
|
||||
case 'transmitter':
|
||||
$client->bindTransmitter($systemId, $password);
|
||||
break;
|
||||
case 'receiver':
|
||||
$client->bindReceiver($systemId, $password);
|
||||
break;
|
||||
default:
|
||||
$client->bindTransceiver($systemId, $password);
|
||||
}
|
||||
|
||||
$clients[$name] = $client;
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function doUnbind(array $body): array
|
||||
{
|
||||
global $clients;
|
||||
|
||||
$clients[$body['name']]->close();
|
||||
unset($clients[$body['name']]);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
function doEnquireLink(array $body): array
|
||||
{
|
||||
global $clients;
|
||||
|
||||
$clients[$body['name']]->enquireLink();
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/** Single, non-CSMS submit - used by the refusal and bind-direction scenarios. */
|
||||
function doSubmit(array $body): array
|
||||
{
|
||||
global $clients;
|
||||
|
||||
$client = $clients[$body['name']];
|
||||
$dataCoding = (int) ($body['dataCoding'] ?? SMPP::DATA_CODING_DEFAULT);
|
||||
$message = encodeBody($body['text'], $dataCoding);
|
||||
$from = new Address($body['from']);
|
||||
$to = new Address($body['to']);
|
||||
|
||||
try {
|
||||
$messageId = $client->sendSMS($from, $to, $message, null, $dataCoding);
|
||||
|
||||
return ['messageId' => $messageId, 'ok' => true];
|
||||
} catch (SmppException $e) {
|
||||
return ['ok' => false, 'status' => $e->getCode()];
|
||||
}
|
||||
}
|
||||
|
||||
/** One message in each of the three CSMS spellings; Client::$csmsMethod is process-global, so this
|
||||
* driver serves one request at a time by construction (see the top-of-file note). */
|
||||
function doSendLong(array $body): array
|
||||
{
|
||||
global $clients;
|
||||
|
||||
$client = $clients[$body['name']];
|
||||
Client::$csmsMethod = (int) $body['csmsMethod'];
|
||||
$message = encodeBody($body['text'], SMPP::DATA_CODING_DEFAULT);
|
||||
$from = new Address($body['from']);
|
||||
$to = new Address($body['to']);
|
||||
|
||||
try {
|
||||
$messageId = $client->sendSMS($from, $to, $message, null, SMPP::DATA_CODING_DEFAULT);
|
||||
|
||||
return ['lastMessageId' => $messageId, 'ok' => true];
|
||||
} catch (SmppException $e) {
|
||||
return ['ok' => false, 'status' => $e->getCode()];
|
||||
}
|
||||
}
|
||||
|
||||
function doReceive(array $body): array
|
||||
{
|
||||
global $clients;
|
||||
|
||||
$client = $clients[$body['name']];
|
||||
$sms = $client->readSMS();
|
||||
|
||||
if ($sms === false) {
|
||||
return ['ok' => true, 'received' => false];
|
||||
}
|
||||
|
||||
return [
|
||||
'dataCoding' => $sms->dataCoding,
|
||||
'esmClass' => $sms->esmClass,
|
||||
'from' => $sms->source->value,
|
||||
'hex' => bin2hex($sms->message),
|
||||
'isReceipt' => $sms instanceof DeliveryReceipt,
|
||||
'ok' => true,
|
||||
'received' => true,
|
||||
'text' => decodeBody($sms->message, $sms->dataCoding),
|
||||
'to' => $sms->destination->value,
|
||||
];
|
||||
}
|
||||
|
||||
const ROUTES = [
|
||||
'/bind' => 'doBind',
|
||||
'/enquireLink' => 'doEnquireLink',
|
||||
'/receive' => 'doReceive',
|
||||
'/sendLong' => 'doSendLong',
|
||||
'/submit' => 'doSubmit',
|
||||
'/unbind' => 'doUnbind',
|
||||
];
|
||||
|
||||
// Minimal single-connection HTTP server: no framework, one request handled fully before the next
|
||||
// is accepted - which is exactly what a synchronous, blocking SMPP client needs (see top note).
|
||||
$listen = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
|
||||
|
||||
if ($listen === false) {
|
||||
fwrite(STDERR, "listen failed: $errstr\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDERR, "php-smpp driver listening on 8080, target $targetHost:$targetPort\n");
|
||||
|
||||
while (true) {
|
||||
$conn = @stream_socket_accept($listen, -1);
|
||||
|
||||
if ($conn === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$requestLine = fgets($conn);
|
||||
$method = 'GET';
|
||||
$path = '/';
|
||||
|
||||
if ($requestLine !== false && preg_match('#^(\\S+)\\s+(\\S+)#', $requestLine, $m)) {
|
||||
$method = $m[1];
|
||||
$path = parse_url($m[2], PHP_URL_PATH) ?? '/';
|
||||
}
|
||||
|
||||
$contentLength = 0;
|
||||
|
||||
while (($line = fgets($conn)) !== false && trim($line) !== '') {
|
||||
if (preg_match('/^Content-Length:\\s*(\\d+)/i', $line, $m)) {
|
||||
$contentLength = (int) $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
$rawBody = '';
|
||||
|
||||
while (strlen($rawBody) < $contentLength) {
|
||||
$chunk = fread($conn, $contentLength - strlen($rawBody));
|
||||
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$rawBody .= $chunk;
|
||||
}
|
||||
$body = $rawBody === '' || $rawBody === false ? [] : (json_decode($rawBody, true) ?? []);
|
||||
|
||||
if ($path === '/health') {
|
||||
$payload = ['ok' => true];
|
||||
$status = 200;
|
||||
} elseif (isset(ROUTES[$path])) {
|
||||
try {
|
||||
$payload = ROUTES[$path]($body);
|
||||
$status = 200;
|
||||
} catch (Throwable $e) {
|
||||
$payload = ['error' => get_class($e) . ': ' . $e->getMessage()];
|
||||
$status = 500;
|
||||
}
|
||||
} else {
|
||||
$payload = ['error' => 'no such route'];
|
||||
$status = 404;
|
||||
}
|
||||
|
||||
$json = json_encode($payload);
|
||||
$statusText = $status === 200 ? 'OK' : ($status === 404 ? 'Not Found' : 'Internal Server Error');
|
||||
fwrite(
|
||||
$conn,
|
||||
"HTTP/1.1 $status $statusText\r\nContent-Type: application/json\r\nContent-Length: "
|
||||
. strlen($json) . "\r\nConnection: close\r\n\r\n" . $json
|
||||
);
|
||||
fclose($conn);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12.14-slim-bookworm
|
||||
|
||||
RUN pip install --no-cache-dir smpplib==2.2.4
|
||||
|
||||
WORKDIR /app
|
||||
COPY driver.py .
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["python3", "/app/driver.py"]
|
||||
CMD ["node", "2775"]
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP-driven python-smpplib ESME: binds named sessions against the target SMPP server and
|
||||
performs one action per request, answering with the result as JSON. Kept alive as one process so a
|
||||
session survives across requests, the way jsmpp's Java driver does (see AGENTS.md)."""
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import smpplib.client
|
||||
import smpplib.consts
|
||||
import smpplib.exceptions
|
||||
import smpplib.gsm
|
||||
import smpplib.smpp
|
||||
|
||||
TARGET_HOST = sys.argv[1] if len(sys.argv) > 1 else "node"
|
||||
TARGET_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 2775
|
||||
|
||||
GSM_TABLE = smpplib.gsm.GSM_CHARACTER_TABLE
|
||||
|
||||
MODES = {
|
||||
"receiver": "bind_receiver",
|
||||
"transceiver": "bind_transceiver",
|
||||
"transmitter": "bind_transmitter",
|
||||
}
|
||||
|
||||
|
||||
def as_text(value):
|
||||
return value.decode() if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def gsm_decode(data: bytes) -> str:
|
||||
"""Inverse of smpplib's own gsm_encode(), through its own (vendor-specific) table - used to
|
||||
decode what this driver receives, so a mismatch against what was sent is smpplib's own table,
|
||||
not a guess at the real GSM 03.38 one."""
|
||||
chars = []
|
||||
i = 0
|
||||
while i < len(data):
|
||||
byte = data[i]
|
||||
if byte == 0x1B and i + 1 < len(data):
|
||||
chars.append(GSM_TABLE[0x80 + data[i + 1]])
|
||||
i += 2
|
||||
else:
|
||||
chars.append(GSM_TABLE[byte])
|
||||
i += 1
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def decode_body(data: bytes, data_coding: int) -> str:
|
||||
if data_coding in (0, 1):
|
||||
return gsm_decode(data)
|
||||
if data_coding == 3:
|
||||
return data.decode("latin-1")
|
||||
if data_coding == 8:
|
||||
whole = len(data) - (len(data) % 2)
|
||||
return data[:whole].decode("utf-16-be")
|
||||
return data.hex()
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.send_lock = threading.Lock()
|
||||
self.received = []
|
||||
self.acks = {}
|
||||
self.ack_events = {}
|
||||
self.reader_thread = None
|
||||
self.reader_running = False
|
||||
self.reader_error = None
|
||||
|
||||
def wait_ack(self, sequence, budget=8.0):
|
||||
event = self.ack_events.setdefault(sequence, threading.Event())
|
||||
event.wait(budget)
|
||||
return self.acks.get(sequence)
|
||||
|
||||
|
||||
SESSIONS = {}
|
||||
SESSIONS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def session_for(name):
|
||||
with SESSIONS_LOCK:
|
||||
return SESSIONS[name]
|
||||
|
||||
|
||||
def do_bind(body):
|
||||
name = body["name"]
|
||||
mode = body["mode"]
|
||||
timeout_secs = float(body.get("timeoutSecs", 5))
|
||||
|
||||
client = smpplib.client.Client(TARGET_HOST, TARGET_PORT, timeout=timeout_secs, allow_unknown_opt_params=True)
|
||||
client.connect()
|
||||
|
||||
kwargs = {"system_id": body["systemId"], "password": body["password"]}
|
||||
if body.get("interfaceVersion") is not None:
|
||||
kwargs["interface_version"] = int(body["interfaceVersion"])
|
||||
|
||||
getattr(client, MODES[mode])(**kwargs)
|
||||
|
||||
session = Session(client)
|
||||
|
||||
def on_received(pdu, **_kwargs):
|
||||
data = pdu.short_message or b""
|
||||
session.received.append({
|
||||
"dataCoding": pdu.data_coding,
|
||||
"esmClass": pdu.esm_class,
|
||||
"from": as_text(pdu.source_addr),
|
||||
"hex": data.hex(),
|
||||
"text": decode_body(data, pdu.data_coding),
|
||||
"to": as_text(pdu.destination_addr),
|
||||
})
|
||||
return smpplib.consts.SMPP_ESME_ROK
|
||||
|
||||
def on_sent(pdu, **_kwargs):
|
||||
session.acks[pdu.sequence] = {
|
||||
"messageId": as_text(getattr(pdu, "message_id", None)),
|
||||
"status": int(pdu.status),
|
||||
}
|
||||
session.ack_events.setdefault(pdu.sequence, threading.Event()).set()
|
||||
|
||||
def on_error_pdu(pdu):
|
||||
# Overrides the default handler, which raises: a refusing status must reach
|
||||
# message_sent_handler like any other response, not tear down the read loop.
|
||||
if pdu.command == "submit_sm_resp":
|
||||
on_sent(pdu)
|
||||
|
||||
client.set_message_received_handler(on_received)
|
||||
client.set_message_sent_handler(on_sent)
|
||||
client.set_error_pdu_handler(on_error_pdu)
|
||||
|
||||
with SESSIONS_LOCK:
|
||||
SESSIONS[name] = session
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def do_start_reader(body):
|
||||
session = session_for(body["name"])
|
||||
auto_send_enquire_link = bool(body.get("autoSendEnquireLink", True))
|
||||
|
||||
if session.reader_running:
|
||||
return {"ok": True}
|
||||
|
||||
def run():
|
||||
session.reader_running = True
|
||||
try:
|
||||
while True:
|
||||
session.client.read_once(auto_send_enquire_link=auto_send_enquire_link)
|
||||
except Exception as exc: # noqa: BLE001 - recorded, not raised: this is a driver thread
|
||||
session.reader_error = f"{type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
session.reader_running = False
|
||||
|
||||
session.reader_thread = threading.Thread(target=run, daemon=True)
|
||||
session.reader_thread.start()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def encode_body(text, data_coding):
|
||||
if data_coding == 0:
|
||||
return smpplib.gsm.gsm_encode(text)
|
||||
if data_coding == 3:
|
||||
return text.encode("latin-1")
|
||||
if data_coding == 8:
|
||||
return text.encode("utf-16-be")
|
||||
raise ValueError(f"unsupported dataCoding {data_coding}")
|
||||
|
||||
|
||||
def do_submit(body):
|
||||
# Does not wait for the submit_sm_resp: a single-segment message is only answered once the
|
||||
# caller's own "sms" handler calls sendResp(), which the caller can only do after seeing this
|
||||
# call return - waiting here would deadlock exactly that handshake. Poll /ack for the result.
|
||||
session = session_for(body["name"])
|
||||
data_coding = int(body["dataCoding"])
|
||||
payload = encode_body(body.get("text", ""), data_coding) if "text" in body else b""
|
||||
|
||||
if body.get("extraHex"):
|
||||
payload += bytes.fromhex(body["extraHex"])
|
||||
|
||||
with session.send_lock:
|
||||
pdu = session.client.send_message(
|
||||
source_addr=body["from"],
|
||||
destination_addr=body["to"],
|
||||
short_message=payload,
|
||||
data_coding=data_coding,
|
||||
esm_class=int(body.get("esmClass", 0)),
|
||||
)
|
||||
sequence = pdu.sequence
|
||||
|
||||
return {"ok": True, "sequence": sequence}
|
||||
|
||||
|
||||
def do_ack(query):
|
||||
session = session_for(query["name"][0])
|
||||
sequence = int(query["sequence"][0])
|
||||
ack = session.acks.get(sequence)
|
||||
|
||||
if ack is None:
|
||||
return {"found": False, "ok": True}
|
||||
|
||||
return {"found": True, "messageId": ack["messageId"], "ok": True, "status": ack["status"]}
|
||||
|
||||
|
||||
def do_submit_long(body):
|
||||
session = session_for(body["name"])
|
||||
data_coding = int(body["dataCoding"])
|
||||
parts, encoding, esm_class = smpplib.gsm.make_parts(body["text"], encoding=data_coding, use_udhi=True)
|
||||
|
||||
results = []
|
||||
|
||||
for part in parts:
|
||||
with session.send_lock:
|
||||
pdu = session.client.send_message(
|
||||
source_addr=body["from"],
|
||||
destination_addr=body["to"],
|
||||
short_message=part,
|
||||
data_coding=encoding,
|
||||
esm_class=esm_class,
|
||||
)
|
||||
sequence = pdu.sequence
|
||||
|
||||
ack = session.wait_ack(sequence, budget=8)
|
||||
results.append(ack)
|
||||
|
||||
return {"ok": all(results), "parts": len(parts), "results": results}
|
||||
|
||||
|
||||
def do_enquire_link(body):
|
||||
session = session_for(body["name"])
|
||||
|
||||
with session.send_lock:
|
||||
pdu = smpplib.smpp.make_pdu("enquire_link", client=session.client)
|
||||
session.client.send_pdu(pdu)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def do_received(name):
|
||||
session = session_for(name)
|
||||
|
||||
return {"ok": True, "received": session.received}
|
||||
|
||||
|
||||
def do_status(name):
|
||||
session = session_for(name)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"readerError": session.reader_error,
|
||||
"readerRunning": session.reader_running,
|
||||
"receivedCount": len(session.received),
|
||||
}
|
||||
|
||||
|
||||
def do_idle_silent(body):
|
||||
"""Sleeps `seconds` sending nothing at all - no reader thread, no enquire_link - then does one
|
||||
read attempt to say whether the peer (our server) closed the link while it was silent."""
|
||||
session = session_for(body["name"])
|
||||
time.sleep(float(body["seconds"]))
|
||||
|
||||
session.client._socket.settimeout(2)
|
||||
|
||||
try:
|
||||
session.client.read_pdu()
|
||||
|
||||
return {"closed": False, "ok": True}
|
||||
except socket.timeout:
|
||||
return {"closed": False, "ok": True}
|
||||
except smpplib.exceptions.ConnectionError:
|
||||
return {"closed": True, "ok": True}
|
||||
|
||||
|
||||
def do_unbind(body):
|
||||
session = session_for(body["name"])
|
||||
|
||||
try:
|
||||
session.client.unbind()
|
||||
except Exception: # noqa: BLE001 - best-effort teardown
|
||||
pass
|
||||
|
||||
session.client.disconnect()
|
||||
|
||||
with SESSIONS_LOCK:
|
||||
del SESSIONS[body["name"]]
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
ROUTES = {
|
||||
"/bind": lambda body, _query: do_bind(body),
|
||||
"/enquireLink": lambda body, _query: do_enquire_link(body),
|
||||
"/idleSilent": lambda body, _query: do_idle_silent(body),
|
||||
"/startReader": lambda body, _query: do_start_reader(body),
|
||||
"/submit": lambda body, _query: do_submit(body),
|
||||
"/submitLong": lambda body, _query: do_submit_long(body),
|
||||
"/unbind": lambda body, _query: do_unbind(body),
|
||||
}
|
||||
|
||||
GET_ROUTES = {
|
||||
"/ack": do_ack,
|
||||
"/received": lambda query: do_received(query["name"][0]),
|
||||
"/status": lambda query: do_status(query["name"][0]),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
def _respond(self, status, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self._respond(200, {"ok": True})
|
||||
return
|
||||
|
||||
parsed = urlparse(self.path)
|
||||
handler = GET_ROUTES.get(parsed.path)
|
||||
|
||||
if handler is None:
|
||||
self._respond(404, {"error": "no such route"})
|
||||
return
|
||||
|
||||
try:
|
||||
self._respond(200, handler(parse_qs(parsed.query)))
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
|
||||
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
|
||||
|
||||
def do_POST(self):
|
||||
handler = ROUTES.get(self.path)
|
||||
|
||||
if handler is None:
|
||||
self._respond(404, {"error": "no such route"})
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
body = json.loads(raw or b"{}")
|
||||
|
||||
try:
|
||||
self._respond(200, handler(body, None))
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
|
||||
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
|
||||
|
||||
|
||||
def main():
|
||||
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
|
||||
print(f"python-smpplib driver listening on 8080, target {TARGET_HOST}:{TARGET_PORT}", file=sys.stderr)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user