diff --git a/AGENTS.md b/AGENTS.md index b0e334a..ba0f185 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ src/ send-sms.ts submitSms composition and the submitSmParams builder send-window.ts SendWindow: the maxOutstanding semaphore session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults + sms-id.ts The notation a peer writes message ids in, normalised for comparison udh.ts User data header: the concatenation fields of a long SMS uuid.ts uuidv7() — the ids the library generates for messages defs/ diff --git a/README.md b/README.md index 38dc3cd..33eee92 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Every one is optional. | `responseTimeout` | `30000` | How long to wait for a response before giving up on it; `0` waits forever. | | `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent; `0` waits forever. | | `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. | +| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. | | `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. | | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). | | `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. | @@ -161,6 +162,19 @@ to tell the two apart. `esm_class` is what tells them apart; where it names no m `receipted_message_id` TLV does, and failing both the message body is read for the standard `id:` and `stat:` receipt fields. +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 +then quietly matches nothing at all. Name each notation and both ids are read into plain decimal +before you see them: + +```javascript +const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); +``` + +An id that is not a number in the notation given is left exactly as it arrived, and `pduObjs` and +`dlr.receipt` carry the id as the peer wrote it either way. + ## Server The simplest possible server — no authentication, listening on port 2775: diff --git a/src/client.ts b/src/client.ts index 58ac4ca..3100758 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,7 @@ import type { ConnectionOptions } from 'node:tls'; import type { Result, VoidResult } from './result.ts'; import type { BindType } from './session-options.ts'; import type { SmppLog } from './log.ts'; +import type { SmsIdFormats } from './sms-id.ts'; import type { Socket } from 'node:net'; export type { BindType }; @@ -29,6 +30,7 @@ export type ClientOptions = { responseTimeout?: number; shutdownTimeout?: number; signal?: AbortSignal; + smsIdFormat?: SmsIdFormats; systemType?: string; tls?: ConnectionOptions | boolean; username?: string; @@ -147,6 +149,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess maxOutstanding: options.maxOutstanding, responseTimeout: options.responseTimeout, shutdownTimeout: options.shutdownTimeout, + smsIdFormat: options.smsIdFormat, sock, ...(options.reconnect ? { diff --git a/src/dlr.ts b/src/dlr.ts index 61f48e2..6ced864 100644 --- a/src/dlr.ts +++ b/src/dlr.ts @@ -1,8 +1,10 @@ import type { MessageState } from './defs/constants.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject } from './pdu.ts'; +import type { SmsIdFormat } from './sms-id.ts'; import { consts, constsById, messageTypeOf } from './defs/constants.ts'; import { decodeMessage } from './message.ts'; +import { normaliseSmsId } from './sms-id.ts'; import { paramNumber, paramText } from './defs/types.ts'; /** @@ -159,8 +161,14 @@ function receiptBody(pduObj: PduObject): string { ).message; } -function receiptId(tlvId: ParamValue | undefined, receipt: Receipt | undefined): string | undefined { - return nonEmptyText(tlvId) ?? nonEmptyText(receipt?.id); +function receiptId( + tlvId: ParamValue | undefined, + receipt: Receipt | undefined, + format: SmsIdFormat | undefined, +): string | undefined { + const id = nonEmptyText(tlvId) ?? nonEmptyText(receipt?.id); + + return id === undefined ? undefined : normaliseSmsId(id, format); } function isMessageState(name: string | undefined): name is MessageState { @@ -184,14 +192,14 @@ function receiptStatus( } /** The delivery report a deliver_sm carries, or nothing when it carries a message instead. */ -export function dlrFromPdu(pduObj: PduObject): Dlr | undefined { +export function dlrFromPdu(pduObj: PduObject, format?: SmsIdFormat): Dlr | undefined { const type = messageType(pduObj); if (type === 'other') return undefined; const body = receiptBody(pduObj); const receipt = body === '' ? undefined : parseReceipt(body); - const smsId = receiptId(pduObj.tlvs.receipted_message_id?.tagValue, receipt); + const smsId = receiptId(pduObj.tlvs.receipted_message_id?.tagValue, receipt, format); const { statusId, statusMsg } = receiptStatus(pduObj.tlvs.message_state?.tagValue, receipt); if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined; diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 4bca232..b273229 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -3,6 +3,7 @@ import type { OnRequest } from './session-options.ts'; import type { PduObject } from './pdu.ts'; import type { Session } from './session.ts'; import type { SmppLog } from './log.ts'; +import type { SmsIdFormat } from './sms-id.ts'; import { Reassembler, decodeSegments } from './reassembly.ts'; import { bindCommands, defaults } from './session-options.ts'; import { concatInfo } from './udh.ts'; @@ -18,6 +19,7 @@ export type IncomingRequestsOptions = { maxReassembly?: number | undefined; onRequest?: OnRequest | undefined; reassemblyTimeout?: number | undefined; + receiptIdFormat?: SmsIdFormat | undefined; session: Session; systemId?: string | undefined; }; @@ -28,6 +30,7 @@ export class IncomingRequests { private readonly log: SmppLog; private readonly onRequest: OnRequest | undefined; private readonly reassembler: Reassembler; + private readonly receiptIdFormat: SmsIdFormat | undefined; private readonly session: Session; private readonly systemId: string; @@ -41,6 +44,7 @@ export class IncomingRequests { maxOctets: options.maxOctets, timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, }); + this.receiptIdFormat = options.receiptIdFormat; this.session = options.session; this.systemId = options.systemId ?? defaults.systemId; } @@ -97,7 +101,7 @@ export class IncomingRequests { /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ private async onDeliverSm(pduObj: PduObject): Promise { - const dlr = dlrFromPdu(pduObj); + const dlr = dlrFromPdu(pduObj, this.receiptIdFormat); if (!dlr) { this.onMessage(pduObj); diff --git a/src/index.ts b/src/index.ts index e0f724f..453943f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export type { SendRespOptions, Sms, SmsInput } from './sms.ts'; export type { ConcatInfo } from './udh.ts'; export type { Result, VoidResult } from './result.ts'; export type { SmppLog } from './log.ts'; +export type { SmsIdFormat, SmsIdFormats } from './sms-id.ts'; export type { AuthenticateInput, AuthenticateResult, diff --git a/src/send-sms.ts b/src/send-sms.ts index b424f20..3688a83 100644 --- a/src/send-sms.ts +++ b/src/send-sms.ts @@ -3,8 +3,10 @@ import type { ParamValue } from './defs/types.ts'; import type { PduObject, PduObjectInput } from './pdu.ts'; import type { Result } from './result.ts'; import type { SmppLog } from './log.ts'; +import type { SmsIdFormat } from './sms-id.ts'; import { consts } from './defs/constants.ts'; import { detect } from './defs/encodings.ts'; +import { normaliseSmsId } from './sms-id.ts'; import { paramText } from './defs/types.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts'; @@ -32,6 +34,7 @@ export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[ export type SendSmsDeps = { log: SmppLog; reference: number; + respIdFormat?: SmsIdFormat | undefined; send: (input: PduObjectInput) => Promise>; }; @@ -98,7 +101,10 @@ function checkSegments(allowed: number, segments: number): Error | undefined { return undefined; } -function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult { +function collectSent( + sent: Result<{ pduObj: PduObject }>[], + format: SmsIdFormat | undefined, +): SendSmsResult { const pduObjs: PduObject[] = []; const smsIds: string[] = []; let failure: Error | undefined; @@ -108,7 +114,7 @@ function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult { failure ??= one.err; } else if (one.pduObj.cmdStatus === 'ESME_ROK') { pduObjs.push(one.pduObj); - smsIds.push(paramText(one.pduObj.params.message_id)); + smsIds.push(normaliseSmsId(paramText(one.pduObj.params.message_id), format)); } else { const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId); @@ -138,5 +144,5 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise params: submitSmParams(sms, segment, { encoding, multipart }), }))); - return collectSent(sent); + return collectSent(sent, deps.respIdFormat); } diff --git a/src/session-options.ts b/src/session-options.ts index 8347353..224738e 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -4,8 +4,10 @@ import type { PduObject } from './pdu.ts'; import type { Result, VoidResult } from './result.ts'; import type { Session } from './session.ts'; import type { SmppLog } from './log.ts'; +import type { SmsIdFormats } from './sms-id.ts'; import type { Sms } from './sms.ts'; import type { Socket } from 'node:net'; +import { isSmsIdFormat } from './sms-id.ts'; export type SessionEvents = { close: []; @@ -83,6 +85,8 @@ export type SessionOptions = { responseTimeout?: number | undefined; /** How long a drain waits for the requests already on the wire. 0 waits forever. */ shutdownTimeout?: number | undefined; + /** The notation the peer writes message ids in, where it is not the one they are compared in. */ + smsIdFormat?: SmsIdFormats | undefined; sock: Socket; /** This end's own identity, answered to the peer in place of the one it sent. */ systemId?: string | undefined; @@ -111,7 +115,7 @@ export const defaults = { * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send * queued behind a slot that is never freed, so the call never settles at all. */ -export function checkSessionOptions(options: SessionCounts): VoidResult { +export function checkSessionOptions(options: CheckableOptions): VoidResult { const limits: [string, number, number][] = [ ['idleTimeout', options.idleTimeout ?? 0, 0], ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], @@ -127,14 +131,31 @@ export function checkSessionOptions(options: SessionCounts): VoidResult { } } + return checkSmsIdFormats(options.smsIdFormat); +} + +function checkSmsIdFormats(smsIdFormat: CheckableOptions['smsIdFormat']): VoidResult { + const formats: [string, string | undefined][] = [ + ['receipt', smsIdFormat?.receipt], + ['submitResp', smsIdFormat?.submitResp], + ]; + + for (const [place, format] of formats) { + if (format !== undefined && !isSmsIdFormat(format)) { + return { err: new Error(`smsIdFormat.${place} must be decimal or hex, got ${format}`) }; + } + } + return {}; } -export type SessionCounts = { +/** What the checker reads, as it arrives: a caller without types can put anything in it. */ +export type CheckableOptions = { idleTimeout?: number | undefined; maxOutstanding?: number | undefined; maxReassembly?: number | undefined; reassemblyTimeout?: number | undefined; responseTimeout?: number | undefined; shutdownTimeout?: number | undefined; + smsIdFormat?: { receipt?: string | undefined; submitResp?: string | undefined } | undefined; }; diff --git a/src/session.ts b/src/session.ts index f5ddffa..1107b64 100644 --- a/src/session.ts +++ b/src/session.ts @@ -120,6 +120,7 @@ export class Session extends EventEmitter { maxReassembly: options.maxReassembly, onRequest: options.onRequest, reassemblyTimeout: options.reassemblyTimeout, + receiptIdFormat: options.smsIdFormat?.receipt, session: this, systemId: options.systemId, }); @@ -209,6 +210,7 @@ export class Session extends EventEmitter { const sent = await submitSms({ log: this.log, reference: this.nextConcatReference(), + respIdFormat: this.options.smsIdFormat?.submitResp, send: input => this.send(input, options), }, sms); diff --git a/src/sms-id.ts b/src/sms-id.ts new file mode 100644 index 0000000..96d3d6a --- /dev/null +++ b/src/sms-id.ts @@ -0,0 +1,32 @@ +/** The notation a peer writes message ids in. */ +export type SmsIdFormat = 'decimal' | 'hex'; + +/** The notation per place the peer writes an id. An omitted place is left as it arrived. */ +export type SmsIdFormats = { + receipt?: SmsIdFormat | undefined; + submitResp?: SmsIdFormat | undefined; +}; + +export function isSmsIdFormat(value: unknown): value is SmsIdFormat { + return value === 'decimal' || value === 'hex'; +} + +// SMPP 3.4 caps message_id at 64 octets, and BigInt on a longer string is a peer-controlled cost. +const maxIdLength = 64; + +const notations = { + decimal: { digits: /^[0-9]+$/, prefix: '' }, + hex: { digits: /^[0-9a-f]+$/i, prefix: '0x' }, +}; + +/** + * The id as a plain decimal value, so an SMSC that answers a submit in one notation and writes the + * receipt in another still correlates. An id the notation cannot read is left as it arrived. + */ +export function normaliseSmsId(id: string, format: SmsIdFormat | undefined): string { + if (format === undefined || id.length > maxIdLength) return id; + + const { digits, prefix } = notations[format]; + + return digits.test(id) ? BigInt(`${prefix}${id}`).toString(10) : id; +} diff --git a/test/dlr.test.ts b/test/dlr.test.ts index d1969b0..b569394 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -206,6 +206,33 @@ describe('dlrFromPdu()', () => { assert.equal(dlr.receipt.sub, 1); assert.equal(dlr.receipt.text, 'hello'); }); + + test('reads the id in the notation the peer writes receipts in', () => { + const hex = dlrFromPdu(deliverSm('id:1a2B stat:DELIVRD err:000 text:'), 'hex'); + + assert.ok(hex); + assert.equal(hex.smsId, '6699'); + assert.equal(hex.receipt?.id, '1a2B', 'the receipt itself keeps the id as it arrived'); + + assert.equal(dlrFromPdu(deliverSm('id:0000123 stat:DELIVRD'), 'decimal')?.smsId, '123'); + assert.equal(dlrFromPdu(deliverSm('nothing scrapable here', { + receipted_message_id: { tagValue: 'FF' }, + }, 0), 'hex')?.smsId, '255'); + }); + + test('leaves an id the notation cannot read as it arrived', () => { + assert.equal(dlrFromPdu(deliverSm('id:beef-1 stat:DELIVRD'), 'hex')?.smsId, 'beef-1'); + assert.equal(dlrFromPdu(deliverSm('id:1a2b stat:DELIVRD'), 'decimal')?.smsId, '1a2b'); + assert.equal(dlrFromPdu(deliverSm('id:0195f0c7 stat:DELIVRD'))?.smsId, '0195f0c7'); + }); + + // Number() reads 9007199254740993 as ...92, which correlates a receipt to the wrong send. + test('reads an id past the safe integer range without losing a digit', () => { + assert.equal( + dlrFromPdu(deliverSm('id:9007199254740993 stat:DELIVRD'), 'decimal')?.smsId, + '9007199254740993', + ); + }); }); describe('receiptCodes', () => { diff --git a/test/readme.test.ts b/test/readme.test.ts index 23b9b7b..886fa9c 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -95,6 +95,26 @@ describe('README: Client', () => { assert.equal((await reported).smsId, smsIds[0]); }); + test('naming the notation the SMSC writes message ids in', async t => { + await answeringServer(t); + + const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); + if (err) throw err; + + closeAfter(t, session); + + const reported = once(resolve => { session.on('dlr', resolve); }); + const { smsIds } = await session.sendSms({ + dlr: true, + from: '46701113311', + message: 'Hello world', + to: '46709771337', + }); + + // The generated ids the server answers with read as no notation, so they arrive untouched. + assert.equal((await reported).smsId, smsIds[0]); + }); + test('the documented sending options', async t => { const smpp = await answeringServer(t); const incoming = once(resolve => { diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 62ec25a..98fee0b 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -15,11 +15,13 @@ import type { TestContext } from 'node:test'; import { Reassembler, decodeSegments } from '../src/reassembly.ts'; import { Session } from '../src/session.ts'; import { DlrMerger } from '../src/dlr-merger.ts'; +import { checkSessionOptions } from '../src/session-options.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { consts } from '../src/defs/constants.ts'; import { errors } from '../src/defs/errors.ts'; import { objToPdu } from '../src/pdu.ts'; +import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; import { silentLog } from '../src/log.ts'; import { submitSms } from '../src/send-sms.ts'; @@ -67,6 +69,34 @@ function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } +/** The server's side of the one connection under test. */ +function peerOf(smpp: SmppServer): Session { + const [peer] = smpp.sessions; + + assert.equal(smpp.sessions.size, 1); + assert.ok(peer); + + return peer; +} + +async function sendReceipt(peer: Session, smsId: string): Promise { + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46701113311', + esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + short_message: `id:${smsId} stat:DELIVRD err:000 text:`, + source_addr: '46709771337', + }, + tlvs: { + message_state: { tagValue: consts.MESSAGE_STATE.DELIVERED }, + receipted_message_id: { tagValue: smsId }, + }, + }); + + assert.equal(sent.err, undefined); +} + type Gate = { open: () => void; passed: Promise }; /** A promise the test opens by hand, guarded by once() against waiting on one it never does. */ @@ -296,34 +326,6 @@ describe('sendSms()', () => { }); describe('reconnect', () => { - /** The server's side of the one connection under test, replaced by every reconnect. */ - function peerOf(smpp: SmppServer): Session { - const [peer] = smpp.sessions; - - assert.equal(smpp.sessions.size, 1); - assert.ok(peer); - - return peer; - } - - async function sendReceipt(peer: Session, smsId: string): Promise { - const sent = await peer.send({ - cmdName: 'deliver_sm', - params: { - destination_addr: '46701113311', - esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, - short_message: `id:${smsId} stat:DELIVRD err:000 text:`, - source_addr: '46709771337', - }, - tlvs: { - message_state: { tagValue: consts.MESSAGE_STATE.DELIVERED }, - receipted_message_id: { tagValue: smsId }, - }, - }); - - assert.equal(sent.err, undefined); - } - test('re-binds after the connection drops, keeping the same session object', async t => { const smpp = await startServer(t); const messages: string[] = []; @@ -810,3 +812,66 @@ describe('graceful shutdown', () => { assert.deepEqual(reported, []); }); }); + +describe('message id notation', () => { + async function sendOne(session: Session, message: string): Promise { + return session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' }); + } + + test('correlates a hex submit_sm_resp against a decimal receipt', async t => { + const smpp = await startServer(t); + + smpp.on('session', bound => { + bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); + }); + + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); + + assert.ok(session); + + const reported = once(resolve => { session.on('dlr', resolve); }); + const sent = await sendOne(session, 'one segment'); + + assert.deepEqual(sent.smsIds, ['6699']); + assert.equal(paramText(sent.pduObjs[0]?.params.message_id), '1a2b', 'the PDU keeps the id it carried'); + + await sendReceipt(peerOf(smpp), '6699'); + + assert.equal((await reported).smsId, sent.smsIds[0]); + }); + + test('leaves the segment ids of a multipart send to merge as they are', async t => { + const smpp = await startServer(t); + + smpp.on('session', bound => { + bound.on('sms', sms => { void sms.sendResp({ smsId: 'beef' }); }); + }); + + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); + + assert.ok(session); + + const merged = once(resolve => { session.on('messageDlr', resolve); }); + const sent = await sendOne(session, 'x'.repeat(200)); + + assert.deepEqual(sent.smsIds, ['beef-1', 'beef-2']); + + for (const smsId of sent.smsIds) { + await sendReceipt(peerOf(smpp), smsId); + } + + assert.equal((await merged).smsId, 'beef'); + }); + + test('refuses a notation it cannot apply', () => { + const checked = checkSessionOptions({ smsIdFormat: { receipt: 'octal' } }); + + assert.ok(checked.err instanceof Error); + assert.match(checked.err.message, /smsIdFormat\.receipt/); + assert.equal(checkSessionOptions({ smsIdFormat: { submitResp: 'hex' } }).err, undefined); + }); +}); diff --git a/todo.md b/todo.md index 5bb0335..a082f0a 100644 --- a/todo.md +++ b/todo.md @@ -5,8 +5,8 @@ rules there constrain every item below. ## Status -The rewrite is **feature complete and green**: 248 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. +The rewrite is **feature complete and green**: the suite, lint and typecheck are clean 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. ```bash docker compose run --rm node npm install @@ -55,6 +55,7 @@ Rules the API follows: | Delivery receipt parsing, TLV and text | `test/dlr.test.ts` | | Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` | | Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | +| `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` | | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.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` | @@ -155,13 +156,6 @@ session message is a change to every call site. - [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing publishes the numbers. -- [ ] **Normalise the message id on both sides of a receipt.** An SMSC that answers `submit_sm_resp` - with a hex `message_id` and sends the receipt's `id:` in decimal — or pads it, or flips its - case — leaves `smsIds` and `dlr.smsId` unequal, so correlation silently yields nothing and the - application sees no receipts at all. A `dlrIdFormat` option (`'hex' | 'decimal' | 'raw'`, or a - 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. - - [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it, but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the