Run python-smpplib and php-smpp against our server: encodings and bind direction hold, no defects
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user