Run python-smpplib and php-smpp against our server: encodings and bind direction hold, no defects

This commit is contained in:
2026-09-06 22:15:20 +02:00
parent ab93833cee
commit a7f3ea70d7
10 changed files with 1587 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
php:
build: ./interop-tests/peers/php
image: interop-php:8.4.25-1d3b53c
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# php only ever dials node:2775 on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:php"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
php:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/php.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
php:
condition: service_healthy
+40
View File
@@ -0,0 +1,40 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
python:
build: ./interop-tests/peers/python
image: interop-python:2.2.4-3.12.14
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# python only ever dials node:2775 on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:python"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
python:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/python.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
python:
condition: service_healthy
+119
View File
@@ -0,0 +1,119 @@
# 06 python-php
Date: 2026-09-06. Repo commit: `ab93833` (working tree, phase 6 changes uncommitted on top). Host
Docker: 29.6.2. Images: `interop-python:2.2.4-3.12.14` (`python:3.12.14-slim-bookworm` +
`pip install smpplib==2.2.4`), `interop-php:8.4.25-1d3b53c` (php-smpp,
`alexandr-mironov/php-smpp`, cloned at commit `1d3b53c2d2b63d51ab70009d956914b7f8903118`, built on
`php:8.4.25-cli`), `nicolaka/netshoot:v0.16` (capture sidecar), `node:24.18.0-bookworm-slim` (test
runner, from the root `compose.yaml`).
## Setup
Each peer is a small driver (`interop-tests/peers/<peer>/driver.{py,php}`) exposing an HTTP command
channel the node test file drives, the same shape as the Java drivers in phase 5 - one long-lived
process per peer holding named client sessions open across requests.
- **python**: `driver.py` runs a `ThreadingHTTPServer`; each named session is a `smpplib.client.Client`
plus a background thread calling `read_once()` in a loop once `/startReader` is called. A plain
`submit_sm` is answered by `@larvit/smpp` only once the application calls `sms.sendResp()` (README,
Server), so `/submit` sends and returns the sequence number immediately rather than blocking for
the ack - a synchronous wait here deadlocks against the node test's own "wait for the sms, then
answer it" flow. `/ack?name=&sequence=` polls the result afterwards. A multipart submit is
answered automatically by the library on arrival, so `/submitLong` keeps its per-part
`wait_ack()`, unaffected by the same deadlock.
- **php**: `driver.php` is a hand-rolled single-connection-at-a-time HTTP server (no framework),
since php-smpp itself is fully synchronous - every send blocks reading its own response on the
same call. This means a plain submit deadlocks against `sendResp()` exactly as above, but there is
no background thread to poll afterwards, so the fix is the opposite one: the node test's global
`session.on('sms', ...)` handler calls `sendResp()` immediately for every arrived sms (the same
pattern `kannel.test.ts` uses for its "maxp1" burst, generalised here to the whole file). A
`deliver_sm` built by hand from the node side and read via `/receive` (php's blocking `readSMS()`)
has the same shape in reverse: the `/receive` call has to be in flight before the `deliver_sm`
goes out, or `session.send()`'s own wait for `deliver_sm_resp` has nothing to unblock it yet.
Build snags, fixed in the Dockerfile, not in `src/`:
- **PHP 8's `sockets` extension returns a `Socket` object from `socket_create()`, not a resource.**
This fork's `Socket::isOpen()` (written pre-PHP8) checks `is_resource($this->socket)` alone, so it
is always `false` on PHP 8.x, and every guarded call - `bindReceiver()`, `bindTransmitter()`,
`bindTransceiver()`, `close()`, `sendCommand()` - throws `SocketTransportException('Socket is not
open')` immediately, regardless of the actual connection. The whole library is unusable on PHP 8+
without this. Patched with a build-time `sed` on `src/transport/Socket.php` (also accept
`$this->socket instanceof \Socket`), not in the vendored source itself.
- `mbstring` needs `libonig-dev` on this base image; the official image warns "mbstring is already
loaded" (it is bundled but not built as a shared extension), harmless.
Two runs each of `./interop-tests/run.py python` and `./interop-tests/run.py php`, all four stable:
python 13/13 both times (frames 449/450, `enquire_link` ~185, `submit_sm`/`submit_sm_resp` 24/24,
`deliver_sm`/`_resp` 3/3, malformed 0, expert errors 0); php 9/9 both times (frames 42,
`bind_transmitter`/`bind_receiver`/`bind_transceiver` and their resps all matched, `submit_sm`/`_resp`
11/11, `deliver_sm`/`_resp` 1/1, malformed 0, expert errors 0).
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| S11 GSM 03.38 basic table + extension table (python) | pass, one peer-side quirk | `python.test.ts` "GSM 03.38 basic table + extension table round trip": whole 127-char table (minus the escape character itself) plus `€[]{}\|~^` round-trips exactly |
| S11 form feed (python) | pass, one peer-side quirk | `python.test.ts` "form feed (0x1B 0x0A)...": raw `1b 0a` appended after `gsm_encode()`'s own bytes decodes to `\f` |
| S11 Latin-1 (python) | pass | `python.test.ts` "Latin-1 round trip" |
| S11 UCS-2 with 一 and an emoji (python) | pass | `python.test.ts` "UCS-2 with 一 and an emoji round trip" |
| S11 reverse direction, GSM and UCS-2 (python) | pass | `python.test.ts` "reverse direction: server sends ... text back, python decodes it the same way" (both) |
| S11 the 0x5F quirk, both ways | pass (documents the peer's own table, not asserted as our defect) | `python.test.ts` "peer quirk, documented both ways: byte 0x5F..." |
| S2 UDH 2/3/10 segments (python) | pass | `python.test.ts` "S2 - long messages", one `sms` per size, `answeredOnArrival` true, ids `<base>-1..N` |
| S2 `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH` (php) | pass, all three | `php.test.ts` "S2 - long messages (php-smpp, three CSMS spellings)" |
| S4 separate TX/RX binds (php) | pass | `php.test.ts` "S4 - bind direction": `boundAs`/`bindAllows()` both ways, `submit_sm` on RX → `ESME_RINVBNDSTS` (status 4) and the peer keeps working, `submit_sm` on TX unaffected, `deliver_sm` reaches RX only (TX's own `/receive` times out) |
| Keepalive, reactive `enquire_link` (python) | pass | `python.test.ts` "Keepalive...": silent past 40s idleTimeout → session closes; `auto_send_enquire_link` with a 10s client timeout → survives the same 45s |
| Refusal via `onRequest` (python, php) | pass, both peers | `python.test.ts`/`php.test.ts` "Refusals via onRequest": `ESME_RTHROTTLED` surfaced as the ack status (python) or a caught `SmppException` code (php); `enquire_link` and a follow-up submit both still work |
## Defects in @larvit/smpp
None found. Every encoding, both directions, every long-message spelling from both peers, the
bind-direction enforcement, the idle/keepalive behaviour, and the `onRequest` refusal path all
matched the README and the spec.
## Peer quirks
- **python-smpplib's `gsm.GSM_CHARACTER_TABLE` disagrees with GSM 03.38 (and this library) at one
code point: 0x5F.** The real table's value there is SECTION SIGN (§); smpplib's own table (its
`gsm.py` docstring already calls it "vendor-specific and not recommended for use") has a backtick
instead. Encoding a literal backtick through `gsm.gsm_encode()` sends byte `0x5F`, which this
library correctly decodes to §; sending §'s own byte back and decoding it through smpplib's table
(as the driver's `gsm_decode()` deliberately does, to compare like for like) reads a backtick, not
a §. Both directions reproduced in `python.test.ts`'s "peer quirk" test. Confirmed by diffing
`smpplib.gsm.GSM_CHARACTER_TABLE[:128]` against this library's own `gsmChars` table (identical at
every other of the 128 positions).
- **`gsm.gsm_encode()` cannot produce a form feed at all.** The character table represents that
extension-table slot with a placeholder backtick, not the literal `\x0c` character, so
`GSM_CHARACTER_TABLE.index('\x0c')` raises `ValueError` (wrapped as `UnicodeError`) for any attempt
to encode one. The driver builds the `1b 0a` byte pair by hand for that one character (see
`python.test.ts`'s "form feed" test) rather than through the library's own encoder.
- **`auto_send_enquire_link` defaults to `True`**, on `read_once()`/`poll()`/`listen()` - not opt-in,
correcting `research/esme-clients-and-validators.md`'s A4 note. The driver passes it explicitly
either way so both keepalive scenarios are deliberate.
- **php-smpp's `GsmEncoderHelper::utf8_to_gsm0338()` has no dictionary entry for ¤ (CURRENCY SIGN,
U+00A4, GSM 03.38 code `0x24`).** `strtr()` only rewrites characters present in its dict; ¤ passes
through as its raw two-byte UTF-8 sequence (`c2 a4`), which this library's GSM decoder (correctly,
since neither byte is in the 128-entry table) renders as two spaces. Reproduced manually (not
asserted in `php.test.ts`, to keep that suite's own assertions unambiguous): submitting
`"price:¤100"` at `data_coding` 0 arrives as `"price: 100"`.
- **This php-smpp fork's `bindTransceiver()` actually works**, against both the plan's premise and
the fork's own inherited README ("You can't connect as a transceiver, otherwise supported by SMPP
v.3.4" - upstream OnlineCity text, unchanged by this fork despite `Client.php` plainly implementing
`bindTransceiver()`). Confirmed directly: `php.test.ts`'s "Peer quirk: bindTransceiver() actually
works" binds one against `server()` and gets `boundAs === 'transceiver'`. `php.test.ts`'s S4
scenarios still use separate TX/RX binds deliberately, since that is the shape target 8 needs
regardless of whether TRX also happens to work.
- **This fork's `composer.json` declares `"license": "LGPL-2.0-or-later"`**, though no top-level
`LICENSE` file exists in the repo - correcting the plan's "no licence declared" for this specific
fork (true of the field, not true of the declaration). Still test-only per the phase brief; not
vendored into this repo either way.
- **`submit_sm()`'s returned message id carries a trailing NUL byte** (`unpack("a*msgid", ...)`
keeps it, unlike PHP's `A`-format unpack which would trim it) - cosmetic, not asserted against in
`php.test.ts`.
## Open questions
- Whether php-smpp's other `is_resource()`-adjacent assumptions (none found beyond `isOpen()` in this
version) would surface on a longer-running session than these scenarios exercise.
- Whether smpplib's `0x5F` table quirk affects any other vendor beyond this one - not checked against
a second Python client, since none of comparable maturity was in scope for this phase.
+35
View File
@@ -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"]
+340
View File
@@ -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);
}
+10
View File
@@ -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"]
+363
View File
@@ -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()
+231
View File
@@ -0,0 +1,231 @@
import assert from 'node:assert/strict';
import test, { after, describe } from 'node:test';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import { paramText } from '../src/defs/types.ts';
import { isCommand, server } from '../src/index.ts';
const DRIVER = process.env.PHP_DRIVER ?? 'php:8080';
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
const REFUSED_DEST = 'REFUSEME';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
const deadline = Date.now() + budget;
let value = get();
while (value === undefined && Date.now() < deadline) {
await delay(50);
value = get();
}
return value;
}
type JsonBody = Record<string, unknown>;
async function post(path: string, body: JsonBody): Promise<JsonBody> {
const res = await fetch(`http://${DRIVER}${path}`, {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
return res.json() as Promise<JsonBody>;
}
const allSms: { sms: Sms; systemId: string }[] = [];
const systemIdBySession = new Map<Session, string>();
const { err: serverErr, server: smpp } = await server({
onRequest: async (session, pduObj) => {
if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false;
await session.sendReturn(pduObj, 'ESME_RTHROTTLED');
return true;
},
port: SMPP_PORT,
});
assert.equal(serverErr, undefined);
assert.ok(smpp);
const smppServer = smpp;
smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
systemIdBySession.set(session, paramText(pduObj.params.system_id));
});
session.on('sms', sms => {
const systemId = systemIdBySession.get(session);
if (systemId) allSms.push({ sms, systemId });
// php-smpp's submit_sm() blocks synchronously reading the response on the same connection
// that sent it, so answering here (rather than after this event's own test observes the
// sms) is the only way that read ever completes - unlike python-smpplib's driver, this one
// has no separate reader thread to poll afterwards.
void sms.sendResp();
});
});
after(async () => {
await smppServer.close();
});
function sessionFor(systemId: string): Session | undefined {
return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId);
}
async function waitForSession(systemId: string, budget = 8000): Promise<Session> {
const found = await waitFor(() => sessionFor(systemId), budget);
assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`);
return found;
}
async function waitForSms(systemId: string, message: string, budget = 8000): Promise<Sms> {
const found = await waitFor(
() => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms,
budget,
);
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`);
return found;
}
async function bindClient(name: string, mode: 'receiver' | 'transceiver' | 'transmitter', opts: Partial<JsonBody> = {}): Promise<void> {
const bound = await post('/bind', { mode, name, password: 'pw', systemId: name, ...opts });
assert.equal(bound.ok, true, JSON.stringify(bound));
}
describe('S2 - long messages (php-smpp, three CSMS spellings)', () => {
const modes: { csmsMethod: number; name: string }[] = [
{ csmsMethod: 0, name: 's2-16bit-tags' },
{ csmsMethod: 1, name: 's2-payload' },
{ csmsMethod: 2, name: 's2-8bit-udh' },
];
for (const { csmsMethod, name } of modes) {
test(`csmsMethod=${String(csmsMethod)} (${name}) reassembles into one sms with the full text`, async () => {
await bindClient(name, 'transceiver');
const text = 'L'.repeat(350);
const sent = await post('/sendLong', { csmsMethod, from: '46700000001', name, text, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
await waitForSms(name, text, 15_000);
});
}
});
describe('S4 - bind direction (php-smpp forces separate TX and RX binds)', () => {
test('php-smpp opens a transmitter bind and a receiver bind, both accepted', async () => {
await bindClient('s4-tx', 'transmitter');
await bindClient('s4-rx', 'receiver', { recvTimeoutMs: 8000 });
const tx = await waitForSession('s4-tx');
const rx = await waitForSession('s4-rx');
assert.equal(tx.boundAs, 'transmitter');
assert.equal(rx.boundAs, 'receiver');
assert.equal(tx.bindAllows('deliver_sm'), false);
assert.equal(rx.bindAllows('submit_sm'), false);
});
test('a submit_sm on the receiver bind is answered ESME_RINVBNDSTS, and the peer keeps working', async () => {
const rx = await waitForSession('s4-rx');
assert.ok(rx);
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-rx', text: 'nope', to: '46700000002' });
assert.equal(refused.ok, false);
assert.equal(refused.status, 4); // ESME_RINVBNDSTS
const link = await post('/enquireLink', { name: 's4-rx' });
assert.equal(link.ok, true);
});
test('submit_sm on the transmitter bind works normally', async () => {
const text = 's4 tx works';
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-tx', text, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s4-tx', text);
assert.equal(sms.session, await waitForSession('s4-tx'));
});
test('a deliver_sm built on the receiver bind reaches php-smpp; the transmitter bind never gets one', async () => {
const rx = await waitForSession('s4-rx');
const text = 's4 mo on rx';
// php-smpp's readSMS() blocks synchronously reading and answering, so it has to be in flight
// before the deliver_sm goes out - awaiting rx.send() first would wait on an answer nothing
// is there yet to send.
const receivePromise = post('/receive', { name: 's4-rx' });
const sent = await rx.send({
cmdName: 'deliver_sm',
params: { destination_addr: '46700000001', short_message: Buffer.from(text), source_addr: '46700000002' },
});
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
const received = await receivePromise;
assert.equal(received.ok, true);
assert.equal(received.received, true);
assert.equal(received.text, text);
const nothingOnTx = await post('/receive', { name: 's4-tx' });
assert.equal(nothingOnTx.ok, true);
assert.equal(nothingOnTx.received, false);
});
});
describe('Refusals via onRequest', () => {
test('a refusing status through onRequest is surfaced to php-smpp as a catchable status, not a hang or close', async () => {
await bindClient('refusal', 'transceiver');
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'nope', to: REFUSED_DEST });
assert.equal(refused.ok, false);
assert.equal(refused.status, 0x58); // ESME_RTHROTTLED
const link = await post('/enquireLink', { name: 'refusal' });
assert.equal(link.ok, true);
const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'still works', to: '46700000002' });
assert.equal(after1.ok, true, JSON.stringify(after1));
});
});
describe('Peer quirk: bindTransceiver() actually works, despite this fork\'s inherited README', () => {
test('a transceiver bind against our server is accepted', async () => {
await bindClient('trx-quirk', 'transceiver');
const session = await waitForSession('trx-quirk');
assert.equal(session.boundAs, 'transceiver');
});
});
+405
View File
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test, { after, describe } from 'node:test';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import { encodings } from '../src/defs/encodings.ts';
import { paramText } from '../src/defs/types.ts';
import { isCommand, server } from '../src/index.ts';
const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080';
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
const REFUSED_DEST = 'REFUSEME';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
const deadline = Date.now() + budget;
let value = get();
while (value === undefined && Date.now() < deadline) {
await delay(50);
value = get();
}
return value;
}
type JsonBody = Record<string, unknown>;
async function post(path: string, body: JsonBody): Promise<JsonBody> {
const res = await fetch(`http://${DRIVER}${path}`, {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
return res.json() as Promise<JsonBody>;
}
async function get(path: string): Promise<JsonBody> {
const res = await fetch(`http://${DRIVER}${path}`);
return res.json() as Promise<JsonBody>;
}
async function bindReader(name: string, opts: Partial<JsonBody> = {}): Promise<void> {
const bound = await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, ...opts });
assert.equal(bound.ok, true, JSON.stringify(bound));
const started = await post('/startReader', { autoSendEnquireLink: true, name });
assert.equal(started.ok, true, JSON.stringify(started));
}
// --- Shared infra: one server() for the whole file, one session per named python-smpplib bind,
// tracked by the system_id the driver binds with - the same pattern as kannel.test.ts's variant. ---
const allSms: { sms: Sms; systemId: string }[] = [];
const systemIdBySession = new Map<Session, string>();
const { err: serverErr, server: smpp } = await server({
onRequest: async (session, pduObj) => {
if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false;
await session.sendReturn(pduObj, 'ESME_RTHROTTLED');
return true;
},
port: SMPP_PORT,
});
assert.equal(serverErr, undefined);
assert.ok(smpp);
const smppServer = smpp;
smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
systemIdBySession.set(session, paramText(pduObj.params.system_id));
});
session.on('sms', sms => {
const systemId = systemIdBySession.get(session);
if (systemId) allSms.push({ sms, systemId });
});
});
after(async () => {
await smppServer.close();
});
function sessionFor(systemId: string): Session | undefined {
return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId);
}
async function waitForSession(systemId: string, budget = 8000): Promise<Session> {
const found = await waitFor(() => sessionFor(systemId), budget);
assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`);
return found;
}
async function waitForSms(systemId: string, message: string, budget = 8000): Promise<Sms> {
const found = await waitFor(
() => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms,
budget,
);
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`);
return found;
}
type ReceivedEntry = { dataCoding: number; esmClass: number; from: string; hex: string; text: string; to: string };
async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => boolean, budget = 8000): Promise<ReceivedEntry> {
const found = await waitFor(async () => {
const status = await get(`/received?name=${name}`);
const list = status.received as ReceivedEntry[];
return list.find(predicate);
}, budget);
assert.ok(found, `no matching received entry for ${name} within ${String(budget)}ms`);
return found;
}
type AckResult = { messageId?: string; status?: number };
// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so
// /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards.
async function waitForAck(name: string, sequence: number, budget = 8000): Promise<AckResult> {
const found = await waitFor(async () => {
const status = await get(`/ack?name=${name}&sequence=${String(sequence)}`);
return status.found ? status : undefined;
}, budget);
assert.ok(found, `no ack for sequence ${String(sequence)} (${name}) within ${String(budget)}ms`);
return found;
}
async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise<void> {
const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII';
const buf = encodings[encName].encode(text);
const sent = await session.send({
cmdName: 'deliver_sm',
params: { data_coding: dataCoding, destination_addr: sms.from, short_message: buf, source_addr: sms.to },
});
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
// The real GSM 03.38 basic table (128 unique chars), as this library decodes it.
const ourTable =
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
// python-smpplib's own gsm.GSM_CHARACTER_TABLE[:128] - identical except at 0x5F, a backtick where
// the real table (and this library) has a section sign.
const theirTable =
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà';
const ESC_INDEX = 27;
const sendBasic = theirTable.slice(0, ESC_INDEX) + theirTable.slice(ESC_INDEX + 1);
const expectBasic = ourTable.slice(0, ESC_INDEX) + ourTable.slice(ESC_INDEX + 1);
const extensionChars = '€[]{}\\|~^';
describe('S11 - encodings (python-smpplib)', () => {
test('GSM 03.38 basic table + extension table round trip (python -> server)', async () => {
await bindReader('s11-basic');
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-basic', text: sendBasic + extensionChars, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-basic', expectBasic + extensionChars);
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0);
});
test('form feed (0x1B 0x0A), which smpplib\'s own gsm_encode() cannot build, round trips raw', async () => {
await bindReader('s11-ff');
const sent = await post('/submit', { dataCoding: 0, extraHex: '1b0a', from: '46700000001', name: 's11-ff', text: 'before-', to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-ff', 'before-\f');
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0);
});
test('Latin-1 round trip', async () => {
await bindReader('s11-latin1');
const text = 'café £ ñ ¿¡ © ®';
const sent = await post('/submit', { dataCoding: 3, from: '46700000001', name: 's11-latin1', text, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-latin1', text);
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0);
});
test('UCS-2 with 一 and an emoji round trip', async () => {
await bindReader('s11-ucs2');
const text = '一😀hello';
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-ucs2', text, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-ucs2', text);
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0);
});
test('reverse direction: server sends GSM text back, python decodes it the same way', async () => {
await bindReader('s11-echo-gsm');
const text = 'hello from the server';
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-echo-gsm', text: 'seed', to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-echo-gsm', 'seed');
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0);
const session = sessionFor('s11-echo-gsm');
assert.ok(session);
await echoBack(session, sms, 0, text);
const received = await waitForReceived('s11-echo-gsm', e => e.text === text);
assert.equal(received.dataCoding, 0);
});
test('reverse direction: server sends UCS-2 text back, python decodes it the same way', async () => {
await bindReader('s11-echo-ucs2');
const seed = 'seed-ucs2';
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-echo-ucs2', text: seed, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-echo-ucs2', seed);
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0);
const session = sessionFor('s11-echo-ucs2');
assert.ok(session);
const text = '一😀back';
await echoBack(session, sms, 8, text);
const received = await waitForReceived('s11-echo-ucs2', e => e.text === text);
assert.equal(received.dataCoding, 8);
});
test('peer quirk, documented both ways: byte 0x5F is section sign here, backtick in smpplib\'s own table', async () => {
await bindReader('s11-quirk');
// python encodes a literal backtick through its own gsm_encode(), landing on byte 0x5F -
// this library decodes that byte to SECTION SIGN, the real GSM 03.38 value.
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-quirk', text: '`', to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
const sms = await waitForSms('s11-quirk', '§');
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0);
// Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads
// the same byte back as a backtick, not a section sign - the mismatch shows up both ways.
const session = sessionFor('s11-quirk');
assert.ok(session);
await echoBack(session, sms, 0, '§');
const received = await waitForReceived('s11-quirk', e => e.dataCoding === 0);
assert.equal(received.text, '`');
});
});
describe('S2 - long messages (python-smpplib, UDH)', () => {
for (const segments of [2, 3, 10]) {
test(`${String(segments)} segments reassemble whole, each answered on arrival`, async () => {
const name = `s2-udh-${String(segments)}`;
await bindReader(name);
const text = Array.from({ length: 153 * (segments - 1) + 10 }, (_, i) => String(i % 10)).join('');
const sent = await post('/submitLong', { dataCoding: 0, from: '46700000001', name, text, to: '46700000002' });
assert.equal(sent.ok, true, JSON.stringify(sent));
assert.equal(sent.parts, segments);
const sms = await waitForSms(name, text, 15_000);
assert.equal(sms.answeredOnArrival, true);
const results = sent.results as { messageId: string; status: number }[];
assert.equal(results.length, segments);
for (const [i, result] of results.entries()) {
assert.equal(result.status, 0);
assert.equal(result.messageId, `${sms.smsId}-${String(i + 1)}`);
}
});
}
});
describe('Keepalive - python-smpplib\'s reactive enquire_link', () => {
test('idle past idleTimeout (40s) with no keepalive: our server drops the session', async () => {
const name = 'keepalive-none';
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 50 });
const session = await waitForSession(name);
const closed: unknown[] = [];
session.on('close', () => { closed.push(undefined); });
const idle = await post('/idleSilent', { name, seconds: 45 });
assert.equal(idle.ok, true, JSON.stringify(idle));
assert.equal(idle.closed, true);
assert.deepEqual(closed, [undefined]);
});
test('idle past idleTimeout (40s) with auto_send_enquire_link: the link survives', async () => {
const name = 'keepalive-auto';
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 10 });
await waitForSession(name);
await post('/startReader', { autoSendEnquireLink: true, name });
const closed: unknown[] = [];
const session = sessionFor(name);
assert.ok(session);
session.on('close', () => { closed.push(undefined); });
await delay(45_000);
const status = await get(`/status?name=${name}`);
assert.equal(status.readerRunning, true);
assert.equal(status.readerError, null);
assert.deepEqual(closed, []);
assert.ok(sessionFor(name), 'session should still be bound');
});
});
describe('Refusals via onRequest', () => {
test('a refusing status through onRequest is surfaced to python-smpplib, not a hang or close', async () => {
const name = 'refusal';
await bindReader(name);
// Refused through onRequest, before reassembly and before the sms event, so this is answered
// without any sendResp() call - unlike every other submit in this file.
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST });
assert.equal(refused.ok, true, JSON.stringify(refused));
assert.equal((await waitForAck(name, refused.sequence as number)).status, 0x58); // ESME_RTHROTTLED
const link = await post('/enquireLink', { name });
assert.equal(link.ok, true);
const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'still works', to: '46700000002' });
assert.equal(after1.ok, true, JSON.stringify(after1));
const sms = await waitForSms(name, 'still works');
assert.equal((await sms.sendResp()).err, undefined);
assert.equal((await waitForAck(name, after1.sequence as number)).status, 0);
});
});
+4
View File
@@ -14,9 +14,13 @@ EXPERT_SEVERITY_ERROR = "8388608"
# The SMPP port each peer's compose overlay exposes its SMSC on.
PORT_BY_PEER = {
"cloudhopper": 2775,
"dumbclient": 2775,
"jasmin": 2775,
"jsmpp": 2775,
"kannel": 2775,
"php": 2775,
"python": 2775,
"smppload": 2775,
"smppsim": 2775,
"smscsim": 2775,
}