Merge pull request #78 from larvit/intermediate-delivery-reports

Read an intermediate delivery notification as a report, and keep it out of the merge
This commit is contained in:
2026-09-04 10:47:50 +02:00
committed by GitHub
9 changed files with 214 additions and 49 deletions
+43 -13
View File
@@ -18,16 +18,19 @@ one wins. They do not override the hard rules below.
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one; 1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one;
the defect table below is what the alternative costs. the defect table below is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine 2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a request the peer may already have taken is is reported as undetermined rather than guessed; a report the peer marked as not final settles
never re-sent on the library's own initiative; work the peer has no reason to send again is not nothing, so nothing the library concludes may rest on one; a request the peer may already have
dropped. taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and 3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic. sends, keep the traffic.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction 4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound. forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.** 5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. An option retunes a Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
about a message the application sent reaches it as a report rather than as an inbound message, and
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
default or opts out of it; an option does not switch on the thing the caller obviously wanted. default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is 6. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is
published. A new option has to beat "the application can do this itself", and has to keep a published. A new option has to beat "the application can do this itself", and has to keep a
@@ -273,15 +276,42 @@ Grouped by what each one constrains.
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`. and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.** - **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
`MC_DELIVERY_RECEIPT` (0x04) makes it a receipt whatever the body parses to, so a receipt in a The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
format `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an `INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
inbound SMS. Any other named type is not a receipt and its body is not scraped; a message type of 0 `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
or one of the ten reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
receipt on the same footing. A receipt this library recognises never reaches the reassembler, so an scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
SMSC that splits one across segments gets a `dlr` per segment rather than one merged report. The is the handset's word about a message, not the network's. A message type of 0 or one of the ten
`message_state` TLV is authoritative only where it names a state in the table — SMPP reserves reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
0x80-0xFF for MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
`statusMsg` to the body. across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
accepted here is that such a peer stops recognising a transient report as a report at all and hands
its application receipt text as an inbound message, where under 0x04 it would have read the state
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
way has not failed.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>` - **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
+9 -4
View File
@@ -163,7 +163,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. `esm_class` is what tells them apart; where it names no message type a to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
`receipted_message_id` TLV does, and failing both the message body is read for the standard `receipted_message_id` TLV does, and failing both the message body is read for the standard
`id:` and `stat:` receipt fields. `id:` and `stat:` receipt fields. An intermediate delivery notification is the SMSC reporting as
well, not an inbound message.
Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()` 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 returned. Some SMSCs write the two in different notations — a hex `message_id` on the
@@ -231,7 +232,8 @@ await smpp.close(); // stop listening, then drain and close every live sess
``` ```
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `SCHEDULED` and `ENROUTE` go out as intermediate
delivery notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.from(sms.message, A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.from(sms.message,
'latin1')` gives you back the original octets. 'latin1')` gives you back the original octets.
@@ -317,8 +319,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 its `smsId`. | | `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and its `smsId`. |
| `dlr` | A delivery report arrives, one per segment. `smsId` is undefined when the peer marked a receipt whose body carries no readable id. `statusMsg` names `statusId` unless the peer sent a `message_state` this library cannot name — then `statusId` is that raw value and `statusMsg` is whatever the body said, or `UNKNOWN`. | | `dlr` | A delivery report arrives, one per segment. `intermediate` is true where the report is not final: the SMSC either marked it an intermediate notification, or reported `ENROUTE` or `SCHEDULED`. `smsId` is undefined when the peer marked a receipt whose body carries no readable id. `statusMsg` names `statusId` unless the peer sent a `message_state` this library cannot name — then `statusId` is that raw value and `statusMsg` is whatever the body said, or `UNKNOWN`. |
| `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. A base is merged once: a later message the SMSC gives the same ids is reported on through `dlr` alone, and an earlier one still collecting loses its merged report as well. | | `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. A report carrying `intermediate` never counts towards it. 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. A base is merged once: a later message the SMSC gives the same ids is reported on through `dlr` alone, and an earlier one still collecting loses its merged report as well. |
| `close` | The session is over, because nothing will bring the link back. Fires once, whether you closed it or the link failed for good. | | `close` | The session is over, because nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
| `disconnected` | The link dropped and the reconnect loop will retry it. Do not open a replacement client here — the session you hold comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. | | `disconnected` | The link dropped and the reconnect loop will retry it. Do not open a replacement client here — the session you hold comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. |
| `reconnected` | The client re-bound after a drop. | | `reconnected` | The client re-bound after a drop. |
@@ -428,6 +430,9 @@ have worked around any of these, remove the workaround:
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message. - LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec - Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
defines the 7-character `UNDELIV`. defines the 7-character `UNDELIV`.
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
intermediate delivery notification.
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters. - `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
- The multipart reference counter was shared by every session in the process. - The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not actually encrypted. - `tls: true` never performed a handshake, so the connection was not actually encrypted.
+3 -2
View File
@@ -41,7 +41,8 @@ const severity: Record<MessageState, number> = {
* Merges the per-segment receipts of a multipart message into one report, but only when the peer * Merges the per-segment receipts of a multipart message into one report, but only when the peer
* numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An * numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An
* SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it. * SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it.
* A base is merged at most once: a reused id cannot be told apart from a straggler. * A base is merged at most once: a reused id cannot be told apart from a straggler, and a report the
* peer marked intermediate is never counted — it would fill a slot before the real receipt arrives.
*/ */
export class DlrMerger { export class DlrMerger {
private readonly groups: ExpiringGroups<Group>; private readonly groups: ExpiringGroups<Group>;
@@ -95,7 +96,7 @@ export class DlrMerger {
collect(dlr: Dlr): MessageDlr | undefined { collect(dlr: Dlr): MessageDlr | undefined {
this.sweep(); this.sweep();
if (dlr.smsId === undefined) return undefined; if (dlr.intermediate || 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];
+13 -6
View File
@@ -50,6 +50,7 @@ export type Receipt = {
export type Dlr = { export type Dlr = {
doneDate: Date | undefined; doneDate: Date | undefined;
errorCode: string | undefined; errorCode: string | undefined;
intermediate: boolean;
receipt: Receipt | undefined; receipt: Receipt | undefined;
smsId: string | undefined; smsId: string | undefined;
statusId: number; statusId: number;
@@ -127,13 +128,15 @@ export function parseReceipt(message: string): Receipt {
}; };
} }
type MessageType = 'other' | 'receipt' | 'unmarked'; type MessageType = 'intermediate' | 'other' | 'receipt' | 'unmarked';
/** The message types the spec names that are not receipts. It reserves the remaining ten. */ /** SMPP 3.4 Appendix B lists every other receipt state as final. */
const notReceiptTypes: number[] = [ export const transientStates: readonly MessageState[] = ['ENROUTE', 'SCHEDULED'];
/** Written by the far-end SME, not by the MC reporting on a message we submitted. */
const smeMessageTypes: readonly number[] = [
consts.ESM_CLASS.CONVERSATION_ABORT, consts.ESM_CLASS.CONVERSATION_ABORT,
consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT, consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT,
consts.ESM_CLASS.INTERMEDIATE_DELIVERY,
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT, consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
]; ];
@@ -145,7 +148,8 @@ function messageType(pduObj: PduObject): MessageType {
const type = messageTypeOf(paramNumber(pduObj.params.esm_class, 0)); const type = messageTypeOf(paramNumber(pduObj.params.esm_class, 0));
if (type === consts.ESM_CLASS.MC_DELIVERY_RECEIPT) return 'receipt'; if (type === consts.ESM_CLASS.MC_DELIVERY_RECEIPT) return 'receipt';
if (notReceiptTypes.includes(type)) return 'other'; if (type === consts.ESM_CLASS.INTERMEDIATE_DELIVERY) return 'intermediate';
if (smeMessageTypes.includes(type)) return 'other';
return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt'; return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt';
} }
@@ -211,12 +215,15 @@ export function dlrFromPdu(pduObj: PduObject, format: SmsIdFormat = {}): Dlr | u
if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined; if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined;
const state = statusMsg ?? 'UNKNOWN';
return { return {
doneDate: receiptDate(receipt?.doneDate), doneDate: receiptDate(receipt?.doneDate),
errorCode: receipt?.err, errorCode: receipt?.err,
intermediate: type === 'intermediate' || transientStates.includes(state),
receipt, receipt,
smsId, smsId,
statusId, statusId,
statusMsg: statusMsg ?? 'UNKNOWN', statusMsg: state,
}; };
} }
+6 -3
View File
@@ -5,7 +5,7 @@ import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
import { receiptCodes } from './dlr.ts'; import { receiptCodes, transientStates } from './dlr.ts';
import { smppDate } from './message.ts'; import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts'; import { uuidv7 } from './uuid.ts';
@@ -128,6 +128,7 @@ async function sendResp(
/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */
function receiptText(sms: Sms, smsId: string, status: MessageState): string { function receiptText(sms: Sms, smsId: string, status: MessageState): string {
const delivered = status === 'DELIVERED'; const delivered = status === 'DELIVERED';
const failed = !delivered && !transientStates.includes(status);
return [ return [
`id:${smsId}`, `id:${smsId}`,
@@ -136,7 +137,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
`submit date:${smppDate(sms.submitTime)}`, `submit date:${smppDate(sms.submitTime)}`,
`done date:${smppDate(new Date())}`, `done date:${smppDate(new Date())}`,
`stat:${receiptCodes[status]}`, `stat:${receiptCodes[status]}`,
`err:${delivered ? '000' : '001'}`, `err:${failed ? '001' : '000'}`,
'text:', 'text:',
].join(' '); ].join(' ');
} }
@@ -192,7 +193,9 @@ async function sendDlr(
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
destination_addr: sms.from, destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, esm_class: transientStates.includes(status)
? consts.ESM_CLASS.INTERMEDIATE_DELIVERY
: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: receiptText(sms, smsId, status), short_message: receiptText(sms, smsId, status),
source_addr: sms.to, source_addr: sms.to,
}, },
+34 -12
View File
@@ -98,22 +98,23 @@ describe('dlrFromPdu()', () => {
}); });
test('maps every spec status code back to its message state and id', () => { test('maps every spec status code back to its message state and id', () => {
for (const [code, expected, statusId] of [ for (const [code, expected, statusId, intermediate] of [
['DELIVRD', 'DELIVERED', 2], ['DELIVRD', 'DELIVERED', 2, false],
['UNDELIV', 'UNDELIVERABLE', 5], ['UNDELIV', 'UNDELIVERABLE', 5, false],
['EXPIRED', 'EXPIRED', 3], ['EXPIRED', 'EXPIRED', 3, false],
['DELETED', 'DELETED', 4], ['DELETED', 'DELETED', 4, false],
['ACCEPTD', 'ACCEPTED', 6], ['ACCEPTD', 'ACCEPTED', 6, false],
['REJECTD', 'REJECTED', 8], ['REJECTD', 'REJECTED', 8, false],
['ENROUTE', 'ENROUTE', 1], ['ENROUTE', 'ENROUTE', 1, true],
['UNKNOWN', 'UNKNOWN', 7], ['UNKNOWN', 'UNKNOWN', 7, false],
['delivrd', 'DELIVERED', 2], ['delivrd', 'DELIVERED', 2, false],
] as const) { ] as const) {
const dlr = dlrFromPdu(deliverSm(`id:x stat:${code} err:0`)); const dlr = dlrFromPdu(deliverSm(`id:x stat:${code} err:0`));
assert.ok(dlr); assert.ok(dlr);
assert.equal(dlr.statusMsg, expected); assert.equal(dlr.statusMsg, expected);
assert.equal(dlr.statusId, statusId); assert.equal(dlr.statusId, statusId);
assert.equal(dlr.intermediate, intermediate);
} }
}); });
@@ -145,6 +146,7 @@ describe('dlrFromPdu()', () => {
assert.equal(dlr.smsId, undefined); assert.equal(dlr.smsId, undefined);
assert.equal(dlr.statusMsg, 'UNKNOWN'); assert.equal(dlr.statusMsg, 'UNKNOWN');
assert.equal(dlr.statusId, 7); assert.equal(dlr.statusId, 7);
assert.equal(dlr.intermediate, false);
}); });
// pduToObj leaves a UDH-carrying short_message a buffer, so the body needs decoding before it // pduToObj leaves a UDH-carrying short_message a buffer, so the body needs decoding before it
@@ -196,17 +198,37 @@ describe('dlrFromPdu()', () => {
assert.equal(dlr.statusMsg, 'DELIVERED'); assert.equal(dlr.statusMsg, 'DELIVERED');
}); });
test('leaves a message the peer marked as another type to arrive as an SMS', () => { test('leaves a message the far-end SME marked as another type to arrive as an SMS', () => {
for (const esmClass of [ for (const esmClass of [
consts.ESM_CLASS.CONVERSATION_ABORT, consts.ESM_CLASS.CONVERSATION_ABORT,
consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT, consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT,
consts.ESM_CLASS.INTERMEDIATE_DELIVERY,
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT, consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
]) { ]) {
assert.equal(dlrFromPdu(deliverSm(receiptText, undefined, esmClass)), undefined); assert.equal(dlrFromPdu(deliverSm(receiptText, undefined, esmClass)), undefined);
} }
}); });
test('reads a report the peer marked non-final, by either spelling', () => {
const enroute = 'id:0195f0c7 sub:001 dlvrd:000 submit date:2508251430 done date:2508251431 stat:ENROUTE err:000 text:';
const dlr = dlrFromPdu(deliverSm(enroute, undefined, consts.ESM_CLASS.INTERMEDIATE_DELIVERY));
assert.ok(dlr);
assert.equal(dlr.smsId, '0195f0c7');
assert.equal(dlr.statusMsg, 'ENROUTE');
assert.equal(dlr.intermediate, true);
const unreadable = dlrFromPdu(deliverSm('no fields here', undefined, consts.ESM_CLASS.INTERMEDIATE_DELIVERY));
assert.ok(unreadable, 'the marker makes it a report whatever the body parses to');
assert.equal(unreadable.intermediate, true);
const scheduled = dlrFromPdu(deliverSm('id:0195f0c7', { message_state: { tagValue: 0 } }));
assert.ok(scheduled);
assert.equal(scheduled.statusMsg, 'SCHEDULED');
assert.equal(scheduled.intermediate, true, 'an ordinary receipt reporting a transient state is not final either');
});
test('exposes the raw receipt alongside the resolved fields', () => { test('exposes the raw receipt alongside the resolved fields', () => {
const dlr = dlrFromPdu(deliverSm(receiptText)); const dlr = dlrFromPdu(deliverSm(receiptText));
+75 -2
View File
@@ -26,7 +26,7 @@ import { closeAfter, closeListenerAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
import { errors } from '../src/defs/errors.ts'; import { errors } from '../src/defs/errors.ts';
import { objToPdu } from '../src/pdu.ts'; import { objToPdu } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts'; import { paramNumber, paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts'; import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts'; import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts'; import { submitSms } from '../src/send-sms.ts';
@@ -168,6 +168,62 @@ describe('merged delivery reports', () => {
assert.deepEqual(perSegment, ['merge-me-1', 'merge-me-2', 'merge-me-3']); assert.deepEqual(perSegment, ['merge-me-1', 'merge-me-2', 'merge-me-3']);
}); });
test('reports once, on the final receipts, when the peer reports en route first', async t => {
const smpp = await startServer(t);
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { session } = await connect(t, smpp);
assert.ok(session);
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
const reports: Dlr[] = [];
const markers: (number | undefined)[] = [];
session.on('dlr', (dlr, pduObj) => {
reports.push(dlr);
markers.push(paramNumber(pduObj.params.esm_class, 0));
});
const [sms] = await Promise.all([
incoming.then(async received => {
await received.sendResp({ smsId: 'en-route' });
return received;
}),
session.sendSms({
dlr: true,
from: '46701113311',
message: 'x'.repeat(400),
to: '46709771337',
}),
]);
await sms.sendDlr('ENROUTE');
await sms.sendDlr('DELIVERED');
const report = await merged;
assert.equal(report.smsId, 'en-route');
assert.equal(report.statusMsg, 'DELIVERED');
assert.equal(report.segments.length, 3);
const notification = consts.ESM_CLASS.INTERMEDIATE_DELIVERY;
const receipt = consts.ESM_CLASS.MC_DELIVERY_RECEIPT;
assert.deepEqual(reports.map(one => one.intermediate), [true, true, true, false, false, false]);
assert.deepEqual(
markers,
[notification, notification, notification, receipt, receipt, receipt],
'a transient state goes out under the marker the spec gives it',
);
assert.deepEqual(
reports.map(one => one.errorCode),
['000', '000', '000', '000', '000', '000'],
'a message still on its way has not failed',
);
});
test('reports the worst status across the segments', async t => { test('reports the worst status across the segments', async t => {
const smpp = await startServer(t); const smpp = await startServer(t);
const incoming = once<Sms>(resolve => { const incoming = once<Sms>(resolve => {
@@ -203,10 +259,11 @@ describe('merged delivery reports', () => {
}); });
describe('merging segment statuses', () => { describe('merging segment statuses', () => {
function receipt(smsId: string, statusMsg: MessageState): Dlr { function receipt(smsId: string, statusMsg: MessageState, intermediate = false): Dlr {
return { return {
doneDate: undefined, doneDate: undefined,
errorCode: undefined, errorCode: undefined,
intermediate,
receipt: undefined, receipt: undefined,
smsId, smsId,
statusId: consts.MESSAGE_STATE[statusMsg], statusId: consts.MESSAGE_STATE[statusMsg],
@@ -214,6 +271,22 @@ describe('merging segment statuses', () => {
}; };
} }
test('never counts an intermediate report toward a merge', () => {
const merger = new DlrMerger({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
merger.expect(['msg-1', 'msg-2']);
assert.equal(merger.collect(receipt('msg-1', 'ENROUTE', true)), undefined);
assert.equal(merger.collect(receipt('msg-2', 'ENROUTE', true)), undefined);
assert.equal(merger.collect(receipt('msg-1', 'DELIVERED')), undefined);
const merged = merger.collect(receipt('msg-2', 'DELIVERED'));
assert.ok(merged);
assert.equal(merged.statusMsg, 'DELIVERED');
assert.equal(merged.segments.length, 2);
});
// MESSAGE_STATE is a flat enum: ACCEPTED is 6 where UNDELIVERABLE is 5, so reducing on the // MESSAGE_STATE is a flat enum: ACCEPTED is 6 where UNDELIVERABLE is 5, so reducing on the
// wire value called a part-failed message delivered. // wire value called a part-failed message delivered.
test('reports the worse of two states the wire numbers the other way round', () => { test('reports the worse of two states the wire numbers the other way round', () => {
+24 -1
View File
@@ -685,7 +685,7 @@ 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 => { test('hands a client a report as a dlr rather than as an sms', async t => {
const { peer, session } = await inbound(t); const { peer, session } = await inbound(t);
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); }); const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
let messages = 0; let messages = 0;
@@ -711,6 +711,28 @@ describe('receiving', () => {
assert.ok(answered.pduObj); assert.ok(answered.pduObj);
assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp'); assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp');
const notified = once<Dlr>(resolve => { session.on('dlr', resolve); });
const notification = peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
esm_class: consts.ESM_CLASS.INTERMEDIATE_DELIVERY,
short_message: 'id:0195f0c7 stat:ENROUTE err:000 text:',
source_addr: '46701113311',
},
});
const report = await raceWithin(2000, notified);
assert.ok(report, 'an intermediate notification is the MC reporting on our send, not an inbound SMS');
assert.equal(report.intermediate, true);
assert.equal(report.statusMsg, 'ENROUTE');
assert.equal(messages, 0);
const answeredNotification = await notification;
assert.ok(answeredNotification.pduObj);
assert.equal(answeredNotification.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 => {
@@ -1494,6 +1516,7 @@ describe('merged delivery report bounds', () => {
return { return {
doneDate: undefined, doneDate: undefined,
errorCode: undefined, errorCode: undefined,
intermediate: false,
receipt: undefined, receipt: undefined,
smsId, smsId,
statusId: 2, statusId: 2,
+7 -6
View File
@@ -64,6 +64,8 @@ Rules the API follows:
| The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` | | The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `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` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` |
| An intermediate delivery notification read as a report marked `intermediate`, as is a receipt reporting `ENROUTE` or `SCHEDULED`, and never counted into a merge | `test/dlr.test.ts`, `test/session.test.ts`, `test/session-extras.test.ts` |
| A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` |
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` | | A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.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/` |
@@ -131,12 +133,11 @@ session message is a change to every call site.
and applies it to the other is wrong. A budget type both take would close it. Raised by review, and applies it to the other is wrong. A budget type both take would close it. Raised by review,
2026-09-01. 2026-09-01.
- [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type - [ ] **`err:` on a receipt for a state that neither delivered nor failed.** `receiptText()` now
`INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every
non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC
SMS. Kannel treats 0x04, 0x08 and 0x20 alike as report-bearing. Against it: a non-final report never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a
would take a segment's slot in `DlrMerger` and complete the group early. Raised by review, decision.
2026-08-27; needs a decision.
- [ ] **`once()` is copied into four test files, and two copies never give up.** - [ ] **`once()` is copied into four test files, and two copies never give up.**
`session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and `session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and