diff --git a/AGENTS.md b/AGENTS.md
index 53552d0..948b95c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -84,6 +84,7 @@ src/
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
+ message-body.ts Where an inbound body is: short_message, or the message_payload TLV
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
@@ -250,8 +251,8 @@ Grouped by what each one constrains.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
- sending them. Only `submit_sm` and `deliver_sm` are policed by bind direction — the only two the
- library sends and dispatches by it.
+ sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
+ three the library dispatches by it, of which it sends the first two.
- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer
compiles, which never rewired the handlers and so never worked.
@@ -314,6 +315,41 @@ Grouped by what each one constrains.
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
+- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
+ wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
+ phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
+ carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
+ handed the application an empty message
+ ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is
+ the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
+ cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
+ it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
+ spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
+ rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
+ TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
+ which discards a message that is almost certainly present twice over, where goal 3 keeps the
+ traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
+ any other segment.
+
+- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
+ it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
+ SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
+ Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
+ dropped with nothing reported to the application at all. Every command but this one names its own
+ direction, which is why the bind gate and the dispatch never had to be told which end of the link
+ they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
+ delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
+ submission and is read as one, because a report about a message this end never sent is goal 2's
+ wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
+ segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
+ `deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
+ rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
+ nothing else can contradict what the session then binds as. Rejected: grouping the command with
+ `deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
+ `submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
+ `send()` reaches the command raw, and an option choosing which command a message goes out on would
+ be a second spelling of `sendSms()` whose only difference is which peers accept it.
+
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
@@ -514,7 +550,7 @@ Grouped by what each one constrains.
and both exits are logged.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
- `onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
+ `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: an 8-bit concatenation reference is the
diff --git a/README.md b/README.md
index 0a92898..617f140 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,15 @@ to tell the two apart. `esm_class` is what tells them apart; where it names no m
declares, since SMSCs commonly copy the reported message's onto it. An intermediate delivery
notification is the SMSC reporting as well, not an inbound message.
+Where the body sits, and which command carried it, changes none of that. An SMSC that leaves
+`sm_length` 0 and puts the body in the `message_payload` TLV — SMPP's way of carrying up to 64 KB,
+and the only place a `data_sm` has for one — reads exactly like one that fills `short_message`,
+concatenated messages and receipts included. A peer that fills both is read from `short_message`.
+`data_sm` itself is a peer of both `deliver_sm` and `submit_sm`, and its direction says which: a
+client reads one as a delivery, so a message on it arrives as `sms` and a receipt as `dlr`, while a
+`server()` session reads one as the submission it is and always hands it to you as `sms`. Either
+way it is answered `data_sm_resp`.
+
Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()`
returned. Some SMSCs write the two in different notations — a hex `message_id` on the
`submit_sm_resp` and a decimal `id:` in the receipt, or one of them zero-padded — and the comparison
@@ -258,6 +267,11 @@ link the session is:
fail with an `err` before anything reaches the wire.
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
is answered `ESME_RINVBNDSTS`.
+- A `data_sm` carries a message either way, so which end the session is decides what its bind
+ forbids: a client refuses one on a transmitter bind, a `server()` session on a receiver bind.
+ `bindAllows('data_sm')` answers for the direction that reaches this session, since the library
+ sends none. A `Session` you construct yourself is the ESME end, which is what `client()` builds;
+ a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and
is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire.
@@ -445,7 +459,9 @@ if (isCommand(pduObj, 'submit_sm')) {
```
`params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
-same field exactly as it arrived.
+same field exactly as it arrived. Neither holds the body of a PDU that carried it in the
+`message_payload` TLV instead, which a `data_sm` always does — `messageOctets(pduObj)` is the one
+answer to which of the two the peer used.
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`.
@@ -510,6 +526,10 @@ have worked around any of these, remove the workaround:
into a hex string and written back as the ASCII of that string, so every one that made a round
trip went out corrupt. They are `Buffer`s in both directions now, so drop any hex encoding of
your own.
+- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
+ `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost with nothing said.
+ Both reach the application now — a receipt as `dlr`, answered for you, and a message as `sms` for
+ you to answer like any other.
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
- Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME
speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter,
diff --git a/interop-tests/findings/03-jasmin.md b/interop-tests/findings/03-jasmin.md
index cad8a03..4d075cf 100644
--- a/interop-tests/findings/03-jasmin.md
+++ b/interop-tests/findings/03-jasmin.md
@@ -99,6 +99,13 @@ send it over the connector's session, and inspect the `sms` event at a client bo
**Severity.** Medium: silent data loss, not a wire error - the message is fully present on the wire
and Jasmin forwards it faithfully; only this library's read of it is incomplete.
+**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): the body is read from
+`message_payload` where `short_message` carries none. The relay is confirmed on the wire - the
+capture's one `sm_length` 0 `deliver_sm` is Jasmin's own, out of `smpps` to our client, carrying the
+`0x0424` TLV intact. The reproducer's own fixture was wrong as well as the library: it built the TLV
+as Latin-1 while the PDU declared `data_coding` 0, so `_` (GSM 03.38 0x11, Latin-1 0x5F) came back
+as `§` even once the body was read. It now encodes the payload the way the PDU says it is written.
+
### `sar_*`/UDH segmentation from an upstream SMSC reassembles fine (target 3, MO direction) - not reproduced as a defect here
C8's SAR and UDH MO pushes both reassembled into one whole `sms` at our real client. This does not
@@ -121,6 +128,12 @@ Our client's `incomingPduObj` sees it arrive; no `dlr` event ever fires. The rec
**Severity.** Medium: a real, documented Jasmin configuration (`dlr_pdu = data_sm`) that a receipt
depends on silently drops delivery reports, with no error surfaced to the application either.
+**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): `data_sm` is classified and
+answered exactly as `deliver_sm` is, and the receipt reaches `dlr` naming the id the `submit_sm_resp`
+carried, `DELIVERED`. The wire histogram still shows no `data_sm`: `capture` runs
+`network_mode: service:jasmin`, so it only ever sees the main instance's namespace, never
+`jasmin-datasm`'s.
+
### A multi-part MT send deadlocks against Jasmin's serialized per-connector relay - a library/peer interaction, not a wire defect
**What happened.** A 2-, 3-, 10-segment GSM or UCS-2 message sent through Jasmin's `smpps` (our real
@@ -215,6 +228,11 @@ arrives, with `-` off an id the group is opened with. All four multi-se
- Whether the deadlock above is specific to a message requesting a receipt (`registered_delivery`
set) or would also occur for a plain multi-segment send with no `dlr` - not isolated separately,
since every `C3+C7` case here requests one.
+- Whether a real peer accepts a `data_sm_resp` carrying a `message_id`. SMPP 3.4 4.7.2 defines the
+ field, unlike `deliver_sm_resp`'s, and this library fills it when a `data_sm` carried a message -
+ but Jasmin only ever sends one as a receipt, which is answered with the field empty, so the filled
+ case has met no peer. Jasmin FINing over a `deliver_sm_resp` that carried one is the nearest
+ precedent there is.
- Whether Jasmin, given an *upstream* connector that itself defaults to SAR (rather than our
library's own UDH), would relay an MT message using SAR instead of preserving our UDH bytes - the
120s-timeout deadlock always intervened before a second segment could be observed on the wire in
diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts
index f65ff2f..7a3fd83 100644
--- a/interop-tests/jasmin.test.ts
+++ b/interop-tests/jasmin.test.ts
@@ -11,7 +11,7 @@ import { client } from '../src/client.ts';
import { closeAfter } from '../test/teardown.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
-import { splitMessage } from '../src/message.ts';
+import { encodeMessage, splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/send-sms.ts';
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
@@ -246,7 +246,8 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess
source_addr: opts.from,
},
tlvs: {
- message_payload: { tagValue: Buffer.from(opts.message, 'latin1') },
+ // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
+ message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
},
});
}
@@ -565,20 +566,20 @@ describe('C8 (target 2) - message_payload with sm_length 0', () => {
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
- const arrived = await waitFor(() => sms.find(s => s.message === ''), 5000);
+ const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
await session.close({ signal: AbortSignal.abort() });
- // Confirmed (target 2): Jasmin relays message_payload faithfully (sm_length 0, the real text
- // in the TLV) - our own incoming-requests.ts reads short_message only, so the sms that arrives
- // here has the right envelope (from/to) but an empty message, never the text carried in the
- // TLV. See findings/03-jasmin.md for the reproducer.
- assert.ok(arrived, 'expected an sms to arrive (with an empty message, per target 2) for the message_payload push');
+ // Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
+ // whole body has to reach the application from there.
+ assert.ok(arrived, 'expected the message_payload body to arrive as the sms text');
+ assert.equal(arrived.from, TO);
+ assert.equal(arrived.to, FROM);
});
});
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
- test('a receipt thrown as data_sm is not read as a dlr; the raw PDU still arrives', async t => {
+ test('a receipt thrown as data_sm reaches the dlr event', async t => {
await waitForUpstreamSession('datasm');
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
@@ -597,12 +598,16 @@ describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', ()
assert.equal(sent.err, undefined);
+ const [smsId] = sent.smsIds;
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
+ assert.ok(smsId);
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
- await delay(2000);
- assert.deepEqual(dlrs, [], 'data_sm is answered ESME_RINVCMDID and never reaches the dlr event (target 4)');
+ const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000);
+
+ assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`);
+ assert.equal(dlr.statusMsg, 'DELIVERED');
});
});
diff --git a/src/dlr.ts b/src/dlr.ts
index f627231..dca9cd6 100644
--- a/src/dlr.ts
+++ b/src/dlr.ts
@@ -4,6 +4,7 @@ import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { encodings } from './defs/encodings.ts';
+import { messageOctets } from './message-body.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
@@ -157,7 +158,7 @@ function messageType(pduObj: PduObject): MessageType {
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
function receiptBody(pduObj: PduObject): string {
- const octets = pduObj.shortMessageOctets;
+ const octets = messageOctets(pduObj);
if (octets === undefined) return paramText(pduObj.params.short_message);
@@ -203,7 +204,7 @@ function receiptStatus(
return { statusId: tlvState, statusMsg: isMessageState(named) ? named : scraped };
}
-/** The delivery report a deliver_sm carries, or nothing when it carries a message instead. */
+/** The delivery report a deliver_sm or data_sm carries, or nothing where it carries a message. */
export function dlrFromPdu(pduObj: PduObject, format: SmsIdFormat = {}): Dlr | undefined {
const type = messageType(pduObj);
diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts
index 0bf74cb..dc33691 100644
--- a/src/incoming-requests.ts
+++ b/src/incoming-requests.ts
@@ -1,4 +1,3 @@
-import type { CommandName } from './defs/commands.ts';
import type { DlrMerger } from './dlr-merger.ts';
import type { ErrorName } from './defs/errors.ts';
import type { LostGroup, Refusal } from './reassembly.ts';
@@ -10,19 +9,20 @@ import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { HeldMessages } from './held-messages.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
-import { bindCommands, defaults } from './session-options.ts';
+import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatInfo } from './udh.ts';
import { hasUdh } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts';
+import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts';
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
-export function refusedSegmentStatus(cmdName: CommandName, refusal: Refusal): ErrorName {
+export function refusedSegmentStatus(carriedAs: string, refusal: Refusal): ErrorName {
if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS';
- return cmdName === 'deliver_sm' ? 'ESME_RX_T_APPN' : 'ESME_RMSGQFUL';
+ return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
}
const lostReasons: Record = {
@@ -104,9 +104,17 @@ export class IncomingRequests {
return;
}
+ await this.route(pduObj);
+ }
+
+ private async route(pduObj: PduObject): Promise {
switch (pduObj.cmdName) {
+ case 'data_sm':
case 'deliver_sm':
- await this.onDeliverSm(pduObj);
+ // A data_sm at the SMSC end is a submission, and a submission is never a report.
+ await (this.carriedAs(pduObj) === 'submit_sm'
+ ? this.onMessage(pduObj)
+ : this.onDelivery(pduObj));
break;
case 'enquire_link':
await this.session.sendReturn(pduObj);
@@ -161,8 +169,12 @@ export class IncomingRequests {
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
}
+ private carriedAs(pduObj: PduObject): string {
+ return standsInFor(pduObj.cmdName, this.session.linkEnd);
+ }
+
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
- private async onDeliverSm(pduObj: PduObject): Promise {
+ private async onDelivery(pduObj: PduObject): Promise {
const dlr = dlrFromPdu(pduObj, this.smsIdFormat);
if (!dlr) {
@@ -185,9 +197,9 @@ export class IncomingRequests {
* one request at a time never sends the second segment until the first has been answered.
*/
private async onMessage(pduObj: PduObject): Promise {
- const message = pduObj.params.short_message;
+ const message = messageOctets(pduObj);
const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0));
- const concat = carriesUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
+ const concat = carriesUdh && message ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
@@ -198,7 +210,10 @@ export class IncomingRequests {
const collected = this.reassembler.collect(pduObj, concat);
if (!collected.kept) {
- await this.session.sendReturn(pduObj, refusedSegmentStatus(pduObj.cmdName, collected.refusal));
+ await this.session.sendReturn(
+ pduObj,
+ refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal),
+ );
return;
}
diff --git a/src/index.ts b/src/index.ts
index 4812caf..18ac321 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -30,6 +30,7 @@ export {
} from './message.ts';
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
+export { messageOctets } from './message-body.ts';
export { concatInfo } from './udh.ts';
export { PduFramer } from './pdu-framer.ts';
export { uuidv7 } from './uuid.ts';
diff --git a/src/message-body.ts b/src/message-body.ts
new file mode 100644
index 0000000..a244b8f
--- /dev/null
+++ b/src/message-body.ts
@@ -0,0 +1,17 @@
+import type { PduObject } from './pdu.ts';
+
+/**
+ * The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in
+ * `message_payload` with `sm_length` 0, and a `data_sm` has nowhere else to put a body at all.
+ * A filled `short_message` is the peer using the mandatory field, so it wins over a TLV the spec
+ * only allows in its place.
+ */
+export function messageOctets(pduObj: PduObject): Buffer | undefined {
+ const shortMessage = pduObj.shortMessageOctets;
+
+ if (shortMessage !== undefined && shortMessage.length > 0) return shortMessage;
+
+ const payload = pduObj.tlvs.message_payload?.tagValue;
+
+ return Buffer.isBuffer(payload) ? payload : shortMessage;
+}
diff --git a/src/pdu.ts b/src/pdu.ts
index 00e7b11..a23454b 100644
--- a/src/pdu.ts
+++ b/src/pdu.ts
@@ -45,7 +45,10 @@ export type PduObject = {
cmdStatusId: number;
params: Record;
seqNr: number;
- /** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
+ /**
+ * short_message as it arrived, whatever data_coding turned `params.short_message` into. A body
+ * the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
+ */
shortMessageOctets: Buffer | undefined;
tlvs: Record;
};
diff --git a/src/reassembly.ts b/src/reassembly.ts
index 30d849e..7cd6cfe 100644
--- a/src/reassembly.ts
+++ b/src/reassembly.ts
@@ -5,6 +5,7 @@ import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
+import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts';
@@ -108,15 +109,15 @@ export function decodeSegments(pduObjs: PduObject[]): string {
let message = '';
for (const pduObj of pduObjs) {
- const part = pduObj.params.short_message;
+ const part = messageOctets(pduObj);
- message += Buffer.isBuffer(part)
- ? decodeMessage(
- part,
- paramNumber(pduObj.params.data_coding, 0),
- paramNumber(pduObj.params.esm_class, 0),
- ).message
- : paramText(part);
+ if (part === undefined) continue;
+
+ message += decodeMessage(
+ part,
+ paramNumber(pduObj.params.data_coding, 0),
+ paramNumber(pduObj.params.esm_class, 0),
+ ).message;
}
return message;
diff --git a/src/server.ts b/src/server.ts
index 0c1d5c1..c5473f6 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -241,6 +241,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
systemId: options.systemId ?? defaults.systemId,
});
+ session.linkEnd = 'smsc';
server.sessions.add(session);
session.on('close', () => server.sessions.delete(session));
diff --git a/src/session-options.ts b/src/session-options.ts
index f0743ff..2060447 100644
--- a/src/session-options.ts
+++ b/src/session-options.ts
@@ -32,6 +32,9 @@ export const bindCommands: readonly string[] = [
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
+/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
+export type LinkEnd = 'esme' | 'smsc';
+
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
if (cmdName === 'bind_receiver') return 'receiver';
if (cmdName === 'bind_transceiver') return 'transceiver';
@@ -40,14 +43,30 @@ export function bindTypeFromCommand(cmdName: string): BindType | undefined {
return undefined;
}
+/**
+ * Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
+ * its own direction; that one travels either way, so the end it arrived at is what says.
+ */
+export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
+ if (cmdName !== 'data_sm') return cmdName;
+
+ return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
+}
+
/**
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
* has not bound carries everything, since nothing has declared a direction yet.
*/
-export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean {
- if (bindType === 'receiver') return cmdName !== 'submit_sm';
- if (bindType === 'transmitter') return cmdName !== 'deliver_sm';
+export function bindCarries(
+ bindType: BindType | undefined,
+ cmdName: string,
+ linkEnd: LinkEnd,
+): boolean {
+ const carried = standsInFor(cmdName, linkEnd);
+
+ if (bindType === 'receiver') return carried !== 'submit_sm';
+ if (bindType === 'transmitter') return carried !== 'deliver_sm';
return true;
}
diff --git a/src/session.ts b/src/session.ts
index 4ba8ba6..0f01ded 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -3,7 +3,7 @@ import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
-import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
+import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
@@ -54,6 +54,8 @@ export class Session extends EventEmitter {
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
+ /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
+ linkEnd: LinkEnd = 'esme';
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
@@ -154,7 +156,7 @@ export class Session extends EventEmitter {
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean {
- return bindCarries(this.boundAs, cmdName);
+ return bindCarries(this.boundAs, cmdName, this.linkEnd);
}
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
diff --git a/test/dlr.test.ts b/test/dlr.test.ts
index 86b2072..0c23477 100644
--- a/test/dlr.test.ts
+++ b/test/dlr.test.ts
@@ -40,6 +40,32 @@ function deliverSm(
return pduObj;
}
+/** SMPP 3.4 5.3.2.32 puts the body in a TLV instead, which is the only place a data_sm has for one. */
+function payloadPdu(
+ cmdName: 'data_sm' | 'deliver_sm',
+ body: string,
+ shortMessage = Buffer.alloc(0),
+): PduObject {
+ const params = {
+ data_coding: 0,
+ destination_addr: '46701113311',
+ esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
+ source_addr: '46709771337',
+ };
+ const tlvs = { message_payload: { tagValue: Buffer.from(body, 'latin1') } };
+ const built = cmdName === 'deliver_sm'
+ ? objToPdu({ cmdName, params: { ...params, short_message: shortMessage }, seqNr: 1, tlvs })
+ : objToPdu({ cmdName, params, seqNr: 1, tlvs });
+
+ assert.ok(built.buffer);
+
+ const { pduObj } = pduToObj(built.buffer);
+
+ assert.ok(pduObj);
+
+ return pduObj;
+}
+
describe('parseReceipt()', () => {
test('pulls every standard field out of the receipt body', () => {
const receipt = parseReceipt(receiptText);
@@ -299,6 +325,25 @@ describe('dlrFromPdu()', () => {
assert.equal(scheduled.intermediate, true, 'an ordinary receipt reporting a transient state is not final either');
});
+ test('reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm', () => {
+ for (const cmdName of ['data_sm', 'deliver_sm'] as const) {
+ const dlr = dlrFromPdu(payloadPdu(cmdName, textReceipt));
+
+ assert.ok(dlr, `expected a ${cmdName} carrying its receipt in message_payload to read as one`);
+ assert.equal(dlr.smsId, textReceiptId);
+ assert.equal(dlr.statusMsg, 'DELIVERED');
+ assert.equal(dlr.receipt?.stat, 'DELIVRD');
+ }
+ });
+
+ // The spec has the peer leave sm_length 0 when it uses the TLV, so a filled one is what it meant.
+ test('reads short_message where the peer filled both fields', () => {
+ const dlr = dlrFromPdu(payloadPdu('deliver_sm', textReceipt, Buffer.from(receiptText, 'latin1')));
+
+ assert.ok(dlr);
+ assert.equal(dlr.smsId, '0195f0c7');
+ });
+
test('exposes the raw receipt alongside the resolved fields', () => {
const dlr = dlrFromPdu(deliverSm(receiptText));
diff --git a/test/message.test.ts b/test/message.test.ts
index ec30aa4..886f53e 100644
--- a/test/message.test.ts
+++ b/test/message.test.ts
@@ -8,6 +8,8 @@ import {
smppTime,
splitMessage,
} from '../src/message.ts';
+// Through the public surface: an application handed a PduObject needs this same answer.
+import { messageOctets, objToPdu, pduToObj } from '../src/index.ts';
describe('bitCount()', () => {
test('counts GSM characters as seven bits each', () => {
@@ -130,6 +132,41 @@ describe('encodeMessage() and decodeMessage()', () => {
});
});
+describe('messageOctets()', () => {
+ /** A data_sm has no short_message field at all, which is the case that has neither. */
+ function parsed(short: Buffer | undefined, payload?: Buffer) {
+ const { buffer } = short === undefined
+ ? objToPdu({
+ cmdName: 'data_sm',
+ params: { destination_addr: '46709771337', source_addr: '46701113311' },
+ })
+ : objToPdu({
+ cmdName: 'deliver_sm',
+ params: { destination_addr: '46709771337', short_message: short, source_addr: '46701113311' },
+ ...(payload ? { tlvs: { message_payload: { tagValue: payload } } } : {}),
+ });
+
+ assert.ok(buffer);
+
+ const { pduObj } = pduToObj(buffer);
+
+ assert.ok(pduObj);
+
+ return pduObj;
+ }
+
+ test('reads the TLV only where short_message carries nothing', () => {
+ const short = Buffer.from('in the field');
+ const payload = Buffer.from('in the TLV');
+
+ assert.deepEqual(messageOctets(parsed(Buffer.alloc(0), payload)), payload);
+ assert.deepEqual(messageOctets(parsed(short, payload)), short);
+ assert.deepEqual(messageOctets(parsed(short)), short);
+ assert.deepEqual(messageOctets(parsed(Buffer.alloc(0))), Buffer.alloc(0));
+ assert.equal(messageOctets(parsed(undefined)), undefined, 'a PDU with neither carries no body');
+ });
+});
+
describe('smppDate()', () => {
// 0.4.0 used getMonth() without adding one, so January rendered as 00 and every delivery
// receipt carried a date a month in the past.
diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts
index c6c2992..98cee81 100644
--- a/test/session-extras.test.ts
+++ b/test/session-extras.test.ts
@@ -23,7 +23,7 @@ import { Session } from '../src/session.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { PduRefusedError } from '../src/pdu-refusal.ts';
import { objToPdu } from '../src/pdu.ts';
-import { checkSessionOptions } from '../src/session-options.ts';
+import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
@@ -1449,6 +1449,21 @@ describe('reassembly bounds', () => {
};
}
+ /** The same segment with its body where SMPP 3.4 5.3.2.32 allows it instead. */
+ function payloadSegment(reference: number, part: number, total: number): PduObject {
+ const carried = segment(reference, part, total);
+ const body = carried.shortMessageOctets;
+
+ assert.ok(body);
+
+ return {
+ ...carried,
+ params: { ...carried.params, short_message: Buffer.alloc(0) },
+ shortMessageOctets: Buffer.alloc(0),
+ tlvs: { message_payload: { tagId: 0x0424, tagName: 'message_payload', tagValue: body } },
+ };
+ }
+
function collect(
reassembler: Reassembler,
reference: number,
@@ -1504,6 +1519,25 @@ describe('reassembly bounds', () => {
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
});
+ // The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30.
+ test('counts a body carried in message_payload against the octet cap', () => {
+ function collectPayload(maxOctets: number): Collected {
+ const reassembler = new Reassembler({
+ log: silentLog,
+ max: 10,
+ maxOctets,
+ now: () => 0,
+ onLost: () => undefined,
+ timeout: 60_000,
+ });
+
+ return reassembler.collect(payloadSegment(9, 1, 2), { part: 1, reference: 9, total: 2 });
+ }
+
+ assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
+ assert.equal(collectPayload(40).kept, true);
+ });
+
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
test('reports the answered segments of a group that overruns the cap mid-message', () => {
const lost: LostGroup[] = [];
@@ -1730,6 +1764,17 @@ describe('the status a refused segment is answered with', () => {
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
});
+
+ // Which command that is, for the one that travels both ways, is what the end it arrived at says.
+ test('reads a data_sm as the command its direction makes it', () => {
+ assert.equal(standsInFor('data_sm', 'esme'), 'deliver_sm');
+ assert.equal(standsInFor('data_sm', 'smsc'), 'submit_sm');
+ assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
+ assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
+ assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
+ assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full'), 'ESME_RMSGQFUL');
+ assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full'), 'ESME_RX_T_APPN');
+ });
});
describe('a peer that sends the next segment only once the last one is answered', () => {
diff --git a/test/session.test.ts b/test/session.test.ts
index 1d6e100..4e957d5 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -514,6 +514,58 @@ describe('bind direction', () => {
assert.ok(report.err instanceof Error);
assert.match(report.err.message, /transmitter-bound/);
});
+
+ // data_sm carries a message either way, so which end this is decides which way it may travel.
+ test('refuses a data_sm sent to a peer that bound as a transmitter', async t => {
+ const smpp = await startServer(t);
+ const bound = once(resolve => { smpp.on('session', resolve); });
+ const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+
+ assert.ok(session);
+
+ const peer = await bound;
+ const sent = await peer.send({
+ cmdName: 'data_sm',
+ params: { destination_addr: '46709771337', source_addr: '46701113311' },
+ tlvs: { message_payload: { tagValue: Buffer.from('nope') } },
+ });
+
+ assert.ok(sent.pduObj);
+ assert.equal(sent.pduObj.cmdName, 'data_sm_resp');
+ assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
+ });
+
+ test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => {
+ const smpp = await startServer(t);
+
+ smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp()));
+
+ const receiving = await connect(t, smpp, { bindType: 'receiver' });
+
+ assert.ok(receiving.session);
+
+ const refused = await receiving.session.send({
+ cmdName: 'data_sm',
+ params: { destination_addr: '46709771337', source_addr: '46701113311' },
+ tlvs: { message_payload: { tagValue: Buffer.from('nope') } },
+ });
+
+ assert.ok(refused.pduObj);
+ assert.equal(refused.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
+
+ const sending = await connect(t, smpp, { bindType: 'transmitter' });
+
+ assert.ok(sending.session);
+
+ const carried = await sending.session.send({
+ cmdName: 'data_sm',
+ params: { destination_addr: '46709771337', source_addr: '46701113311' },
+ tlvs: { message_payload: { tagValue: Buffer.from('a submission the bind carries') } },
+ });
+
+ assert.ok(carried.pduObj);
+ assert.equal(carried.pduObj.cmdStatus, 'ESME_ROK');
+ });
});
describe('sending', () => {
@@ -707,6 +759,199 @@ describe('receiving', () => {
assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle');
});
+ // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty.
+ test('reads an inbound message the peer carried in message_payload', async t => {
+ const { peer, session } = await inbound(t);
+ const incoming = once(resolve => { session.on('sms', resolve); });
+ const text = 'the whole body, carried in the TLV';
+ const delivered = peer.send({
+ cmdName: 'deliver_sm',
+ params: {
+ destination_addr: '46709771337',
+ short_message: Buffer.alloc(0),
+ source_addr: '46701113311',
+ },
+ tlvs: { message_payload: { tagValue: Buffer.from(text, 'latin1') } },
+ });
+ const sms = await raceWithin(2000, incoming);
+
+ assert.ok(sms, 'a body the peer put in message_payload is still a message');
+ assert.equal(sms.message, text);
+ assert.equal(sms.from, '46701113311');
+ assert.equal(sms.to, '46709771337');
+
+ await sms.sendResp();
+
+ const answered = await delivered;
+
+ assert.ok(answered.pduObj);
+ assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
+ });
+
+ test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => {
+ const { peer, session } = await inbound(t);
+ const incoming = once(resolve => { session.on('sms', resolve); });
+ const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806';
+ const delivered = peer.send({
+ cmdName: 'data_sm',
+ params: { destination_addr: '46709771337', source_addr: '46701113311' },
+ tlvs: { message_payload: { tagValue: Buffer.from('a message carried on the data command', 'latin1') } },
+ });
+ const sms = await raceWithin(2000, incoming);
+
+ assert.ok(sms, 'data_sm is a peer of deliver_sm, not a command to refuse');
+ assert.equal(sms.message, 'a message carried on the data command');
+ assert.equal(sms.from, '46701113311');
+
+ await sms.sendResp({ smsId });
+
+ const answered = await delivered;
+
+ assert.ok(answered.pduObj);
+ assert.equal(answered.pduObj.cmdName, 'data_sm_resp');
+ assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
+ // SMPP 3.4 4.7.2 gives data_sm_resp a message_id, where 4.6.2 leaves deliver_sm_resp's unused.
+ assert.equal(paramText(answered.pduObj.params.message_id), smsId);
+ });
+
+ test('hands a client a receipt carried on data_sm as a dlr', async t => {
+ const { peer, session } = await inbound(t);
+ const reported = once(resolve => { session.on('dlr', resolve); });
+ const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77';
+ let messages = 0;
+
+ session.on('sms', () => { messages++; });
+
+ const delivered = peer.send({
+ cmdName: 'data_sm',
+ params: {
+ destination_addr: '46709771337',
+ esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
+ source_addr: '46701113311',
+ },
+ tlvs: {
+ message_payload: {
+ tagValue: Buffer.from(`id:${smsId} sub:001 dlvrd:001 stat:DELIVRD err:000 text:`, 'latin1'),
+ },
+ },
+ });
+ const dlr = await raceWithin(2000, reported);
+
+ assert.ok(dlr, 'a receipt thrown as data_sm is still a receipt');
+ assert.equal(dlr.smsId, smsId);
+ assert.equal(dlr.statusMsg, 'DELIVERED');
+ assert.equal(messages, 0);
+
+ const answered = await delivered;
+
+ assert.ok(answered.pduObj);
+ assert.equal(answered.pduObj.cmdName, 'data_sm_resp');
+ assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
+ });
+
+ // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends.
+ test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => {
+ const smpp = await startServer(t);
+ const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:';
+ const messages: Sms[] = [];
+ const reports: Dlr[] = [];
+
+ smpp.on('session', peer => {
+ peer.on('dlr', dlr => { reports.push(dlr); });
+ peer.on('sms', sms => {
+ messages.push(sms);
+ void sms.sendResp();
+ });
+ });
+
+ const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+
+ assert.ok(session);
+
+ const submitted = await session.send({
+ cmdName: 'data_sm',
+ params: {
+ destination_addr: '46709771337',
+ esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
+ source_addr: '46701113311',
+ },
+ tlvs: { message_payload: { tagValue: Buffer.from(body, 'latin1') } },
+ });
+
+ assert.ok(submitted.pduObj);
+ assert.equal(submitted.pduObj.cmdStatus, 'ESME_ROK');
+ assert.notEqual(paramText(submitted.pduObj.params.message_id), '');
+ assert.equal(messages[0]?.message, body);
+ assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting');
+ });
+
+ // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
+ test('refuses a data_sm segment a server has no room for with the submit code', async t => {
+ // The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30.
+ const smpp = await startServer(t, { maxOctets: 30 });
+ const { session } = await connect(t, smpp, { bindType: 'transmitter' });
+
+ assert.ok(session);
+
+ const segment = splitMessage('one of two, too big to hold. '.repeat(12), { reference: 0x5C })[0];
+
+ assert.ok(segment);
+
+ const refused = await session.send({
+ cmdName: 'data_sm',
+ params: {
+ destination_addr: '46709771337',
+ esm_class: consts.ESM_CLASS.UDH_INDICATOR,
+ source_addr: '46701113311',
+ },
+ tlvs: { message_payload: { tagValue: segment } },
+ });
+
+ assert.ok(refused.pduObj);
+ assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
+ assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL');
+ });
+
+ test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
+ const { peer, session } = await inbound(t);
+ const incoming = once(resolve => { session.on('sms', resolve); });
+ const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6);
+ const segments = splitMessage(text, { reference: 0x3B });
+
+ assert.ok(segments.length > 1, 'the fixture must need more than one segment');
+
+ const answers: PduObject[] = [];
+
+ for (const segment of segments) {
+ const sent = await peer.send({
+ cmdName: 'deliver_sm',
+ params: {
+ destination_addr: '46709771337',
+ esm_class: consts.ESM_CLASS.UDH_INDICATOR,
+ short_message: Buffer.alloc(0),
+ source_addr: '46701113311',
+ },
+ tlvs: { message_payload: { tagValue: segment } },
+ });
+
+ assert.ok(sent.pduObj);
+ answers.push(sent.pduObj);
+ }
+
+ const sms = await raceWithin(2000, incoming);
+
+ assert.ok(sms, 'the segments join into one message wherever their bodies were carried');
+ assert.equal(sms.message, text);
+ assert.equal(sms.answeredOnArrival, true);
+ assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK'));
+ assert.deepEqual(
+ answers.map(answer => paramText(answer.params.message_id)),
+ answers.map(() => ''),
+ 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused, segment by segment too',
+ );
+ assert.deepEqual(await sms.sendResp(), {});
+ });
+
test('hands a client a report as a dlr rather than as an sms', async t => {
const { peer, session } = await inbound(t);
const reported = once(resolve => { session.on('dlr', resolve); });