Classify a deliver_sm by esm_class and document the messageDlr precondition

This commit is contained in:
2026-08-27 15:52:33 +02:00
parent 9225f6ca23
commit 4c08fe8fa3
8 changed files with 138 additions and 46 deletions
+8
View File
@@ -198,6 +198,14 @@ exactly 140.
dependencies. `@larvit/log` implements it structurally and stays a devDependency, where dependencies. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled. `test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **`esm_class` decides what a `deliver_sm` is, and the body is only read when it names nothing.**
Message type `MC_DELIVERY_RECEIPT` (0x04) makes it a receipt whatever the body parses to, so a
receipt in a format `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of
arriving as an inbound SMS. Any other named type — delivery or user acknowledgement, conversation
abort, intermediate notification — is not a receipt and its body is not scraped. Message type 0
keeps the scrape: SMSCs that send text-only receipts leave `esm_class` at 0, and reading that as
the spec's "default message type" would lose every one of them.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of - **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI `node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
+4 -3
View File
@@ -155,7 +155,8 @@ session.on('sms', async sms => {
``` ```
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
to tell the two apart. to tell the two apart. `esm_class` is what tells them apart; a peer that marks no message type
there has the message body read for the standard `id:` and `stat:` receipt fields instead.
## Server ## Server
@@ -294,8 +295,8 @@ TypeScript users can import `SmppLog` to have the compiler check one.
| Event | Fires when | | Event | Fires when |
| --- | --- | | --- | --- |
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and the `smsId` it was answered with. | | `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and the `smsId` it was answered with. |
| `dlr` | A delivery report arrives, one per segment. | | `dlr` | A delivery report arrives, one per segment. `smsId` is undefined when the peer marked a receipt whose body carries no readable id. |
| `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. | | `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. Merging needs the SMSC to number its segment ids `<base>-<n>`, which is this library's own server's convention — an SMSC that hands out unrelated ids per segment never fires it. |
| `close` | The connection closed. | | `close` | The connection closed. |
| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). | | `reconnected` | The client re-bound after a drop (only with `reconnect` configured). |
| `sessionError` | Something failed on a live session, including a hook or listener that threw. | | `sessionError` | Something failed on a live session, including a hook or listener that threw. |
+2
View File
@@ -93,6 +93,8 @@ export class DlrMerger {
collect(dlr: Dlr): MessageDlr | undefined { collect(dlr: Dlr): MessageDlr | undefined {
this.sweep(); this.sweep();
if (dlr.smsId === undefined) return undefined;
const match = numbered.exec(dlr.smsId); const match = numbered.exec(dlr.smsId);
const base = match?.[1]; const base = match?.[1];
const part = match?.[2]; const part = match?.[2];
+50 -24
View File
@@ -1,4 +1,5 @@
import type { MessageState } from './defs/constants.ts'; import type { MessageState } from './defs/constants.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import { consts, constsById } from './defs/constants.ts'; import { consts, constsById } from './defs/constants.ts';
@@ -46,7 +47,7 @@ export type Dlr = {
doneDate: Date | undefined; doneDate: Date | undefined;
errorCode: string | undefined; errorCode: string | undefined;
receipt: Receipt | undefined; receipt: Receipt | undefined;
smsId: string; smsId: string | undefined;
statusId: number; statusId: number;
statusMsg: string; statusMsg: string;
}; };
@@ -120,34 +121,59 @@ export function parseReceipt(message: string): Receipt {
}; };
} }
/** esm_class bits 5-2 name the message type; the rest are the messaging mode and the GSM features. */
const messageTypeBits = 0x3c;
type MessageType = 'other' | 'receipt' | 'unmarked';
function messageType(pduObj: PduObject): MessageType {
const esmClass = pduObj.params.esm_class;
if (typeof esmClass !== 'number') return 'unmarked';
const type = esmClass & messageTypeBits;
if (type === consts.ESM_CLASS.MC_DELIVERY_RECEIPT) return 'receipt';
return type === 0 ? 'unmarked' : 'other';
}
function receiptId(tlvId: ParamValue | undefined, receipt: Receipt | undefined): string | undefined {
if (typeof tlvId === 'string' && tlvId !== '') return tlvId;
return receipt?.id === '' ? undefined : receipt?.id;
}
function receiptStatus(
tlvState: ParamValue | undefined,
receipt: Receipt | undefined,
): { statusId: number; statusMsg: string | undefined } {
if (typeof tlvState === 'number') {
return { statusId: tlvState, statusMsg: constsById.MESSAGE_STATE?.[tlvState] };
}
const state = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
return { statusId: consts.MESSAGE_STATE[state ?? 'UNKNOWN'], statusMsg: state };
}
/** /**
* Builds a delivery report from a deliver_sm. The message_state and receipted_message_id TLVs are * Builds a delivery report from a deliver_sm, or nothing if the PDU carries a message rather than a
* authoritative when present; otherwise the receipt text is parsed, which is the only thing Kannel * receipt. `esm_class` decides that where the peer sets a message type; where it sets none, the body
* and several other SMSCs send. * is read for the standard receipt fields, which is the only thing Kannel and several other SMSCs
* send. The message_state and receipted_message_id TLVs are authoritative over the body.
*/ */
export function dlrFromPdu(pduObj: PduObject): Dlr | undefined { export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
const type = messageType(pduObj);
if (type === 'other') return undefined;
const message = pduObj.params.short_message; const message = pduObj.params.short_message;
const receipt = typeof message === 'string' ? parseReceipt(message) : undefined; const receipt = typeof message === 'string' ? parseReceipt(message) : undefined;
const receiptState = receiptStates[receipt?.stat?.toUpperCase() ?? '']; const smsId = receiptId(pduObj.tlvs.receipted_message_id?.tagValue, receipt);
const { statusId, statusMsg } = receiptStatus(pduObj.tlvs.message_state?.tagValue, receipt);
const tlvState = pduObj.tlvs.message_state?.tagValue; if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined;
const tlvId = pduObj.tlvs.receipted_message_id?.tagValue;
const smsId = typeof tlvId === 'string' && tlvId !== ''
? tlvId
: receipt?.id;
if (smsId === undefined || smsId === '') return undefined;
const statusMsg = typeof tlvState === 'number'
? constsById.MESSAGE_STATE?.[tlvState]
: receiptState;
if (statusMsg === undefined) return undefined;
const statusId = typeof tlvState === 'number'
? tlvState
: consts.MESSAGE_STATE[receiptState ?? 'UNKNOWN'];
return { return {
doneDate: receiptDate(receipt?.doneDate), doneDate: receiptDate(receipt?.doneDate),
@@ -155,6 +181,6 @@ export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
receipt, receipt,
smsId, smsId,
statusId, statusId,
statusMsg, statusMsg: statusMsg ?? 'UNKNOWN',
}; };
} }
+41 -4
View File
@@ -1,17 +1,22 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import { consts } from '../src/defs/constants.ts';
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts'; import { objToPdu, pduToObj } from '../src/pdu.ts';
import type { PduObject, TlvInput } from '../src/pdu.ts'; import type { PduObject, TlvInput } from '../src/pdu.ts';
const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello'; const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello';
function deliverSm(message: string, tlvs?: Record<string, TlvInput>): PduObject { function deliverSm(
message: string,
tlvs?: Record<string, TlvInput>,
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
): PduObject {
const { buffer } = objToPdu({ const { buffer } = objToPdu({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
destination_addr: '46701113311', destination_addr: '46701113311',
esm_class: 4, esm_class: esmClass,
short_message: message, short_message: message,
source_addr: '46709771337', source_addr: '46709771337',
}, },
@@ -112,8 +117,40 @@ describe('dlrFromPdu()', () => {
assert.equal(dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:2501012560'))?.doneDate, undefined); assert.equal(dlrFromPdu(deliverSm('id:x stat:DELIVRD done date:2501012560'))?.doneDate, undefined);
}); });
test('returns nothing when the PDU identifies no message', () => { test('returns nothing when an unmarked deliver_sm identifies no message', () => {
assert.equal(dlrFromPdu(deliverSm('just a normal sms')), undefined); assert.equal(dlrFromPdu(deliverSm('just a normal sms', undefined, 0)), undefined);
});
test('still reads the body when the peer marks no message type', () => {
const dlr = dlrFromPdu(deliverSm(receiptText, undefined, 0));
assert.ok(dlr);
assert.equal(dlr.smsId, '0195f0c7');
assert.equal(dlr.statusMsg, 'DELIVERED');
});
test('reports a marked receipt whose body it cannot read, rather than an inbound message', () => {
const dlr = dlrFromPdu(deliverSm('a receipt in a format nobody documented'));
assert.ok(dlr);
assert.equal(dlr.smsId, undefined);
assert.equal(dlr.statusMsg, 'UNKNOWN');
assert.equal(dlr.statusId, 7);
const withUdh = consts.ESM_CLASS.MC_DELIVERY_RECEIPT | consts.ESM_CLASS.UDH_INDICATOR;
assert.ok(dlrFromPdu(deliverSm(receiptText, undefined, withUdh)));
});
test('leaves a message the peer marked as another type to arrive as an SMS', () => {
for (const esmClass of [
consts.ESM_CLASS.CONVERSATION_ABORT,
consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT,
consts.ESM_CLASS.INTERMEDIATE_DELIVERY,
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
]) {
assert.equal(dlrFromPdu(deliverSm(receiptText, undefined, esmClass)), undefined);
}
}); });
test('exposes the raw receipt alongside the resolved fields', () => { test('exposes the raw receipt alongside the resolved fields', () => {
+1 -1
View File
@@ -57,7 +57,7 @@ describe('merged delivery reports', () => {
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); }); const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
const perSegment: string[] = []; const perSegment: string[] = [];
session.on('dlr', dlr => perSegment.push(dlr.smsId)); session.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
const [sms] = await Promise.all([ const [sms] = await Promise.all([
incoming.then(async received => { incoming.then(async received => {
+30 -2
View File
@@ -711,6 +711,34 @@ describe('receiving', () => {
assert.equal(answered.pduObj.params.message_id, 'inbound-id'); assert.equal(answered.pduObj.params.message_id, 'inbound-id');
}); });
test('hands a client a receipt it cannot read as a dlr rather than as an sms', async t => {
const { peer, session } = await inbound(t);
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
let messages = 0;
session.on('sms', () => { messages++; });
const delivered = peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: 'a receipt in a format nobody documented',
source_addr: '46701113311',
},
});
const dlr = await raceWithin(2000, reported);
assert.ok(dlr, 'esm_class marks it a receipt, so nothing else may claim it');
assert.equal(dlr.smsId, undefined);
assert.equal(messages, 0);
const answered = await delivered;
assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp');
});
test('reassembles a multipart inbound SMS before the sms event', async t => { test('reassembles a multipart inbound SMS before the sms event', async t => {
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
const { peer, session } = await inbound(t); const { peer, session } = await inbound(t);
@@ -757,7 +785,7 @@ describe('delivery reports', () => {
assert.ok(session); assert.ok(session);
const dlr = once<[{ smsId: string; statusMsg: string }, PduObject]>(resolve => { const dlr = once<[Dlr, PduObject]>(resolve => {
session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); });
}); });
@@ -873,7 +901,7 @@ describe('delivery reports', () => {
const perSegment: string[] = []; const perSegment: string[] = [];
let merged = 0; let merged = 0;
session.on('dlr', dlr => perSegment.push(dlr.smsId)); session.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
session.on('messageDlr', () => { merged++; }); session.on('messageDlr', () => { merged++; });
const [sms] = await Promise.all([ const [sms] = await Promise.all([
+2 -12
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status ## Status
The rewrite is **feature complete and green**: 223 tests, lint and typecheck clean, verified on Node The rewrite is **feature complete and green**: 227 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash ```bash
@@ -56,6 +56,7 @@ Rules the API follows:
| Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` | | Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` |
| Merged multipart DLRs, reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | | Merged multipart DLRs, reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` |
| Every runnable README example | `test/readme.test.ts` | | Every runnable README example | `test/readme.test.ts` |
| Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` |
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` | | Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` | | CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
@@ -100,10 +101,6 @@ session message is a change to every call site.
## Before publishing 1.0.0 ## Before publishing 1.0.0
- [ ] **`messageDlr` is documented without its precondition.** The README event table says it fires
once every segment of a multipart message has been reported on. `DlrMerger` only merges ids
shaped `<base>-<n>`, which is this library's own server's convention, so against most SMSCs it
never fires at all. `src/dlr-merger.ts` states the precondition; the README must too.
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which - [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
`.github/workflows/release.yaml` needs. `.github/workflows/release.yaml` needs.
- [ ] Tag `v1.0.0` to publish. - [ ] Tag `v1.0.0` to publish.
@@ -164,13 +161,6 @@ session message is a change to every call site.
function) applied to both ids before they are compared covers the whole class. The smallest function) applied to both ids before they are compared covers the whole class. The smallest
change on this list for the most real-world breakage removed. change on this list for the most real-world breakage removed.
- [ ] **Detect a receipt by `esm_class`, not by what happens to parse.** `dlrFromPdu()` treats a
`deliver_sm` as a receipt exactly when it can scrape an id and a state out of it, and never
reads `esm_class``MC_DELIVERY_RECEIPT` (0x04) sits in the constants table unused. That
misclassifies both ways: a receipt in a format we cannot parse arrives as an inbound `sms`,
and a mobile-originated message whose text happens to contain `id:… stat:DELIVRD` arrives as a
`dlr`. Read the bits first and keep the scrape as the fallback for a peer that sets none.
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it, - [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
but `dlrFromPdu()` is wired into `IncomingRequests` with no way past it: an application facing but `dlrFromPdu()` is wired into `IncomingRequests` with no way past it: an application facing
a format we do not parse has to listen on `incomingPduObj` and reimplement the dispatch. a format we do not parse has to listen on `incomingPduObj` and reimplement the dispatch.