diff --git a/src/dlr.ts b/src/dlr.ts new file mode 100644 index 0000000..c58fb83 --- /dev/null +++ b/src/dlr.ts @@ -0,0 +1,151 @@ +import type { MessageState } from './defs/constants.ts'; +import type { PduObject } from './pdu.ts'; +import { consts, constsById } from './defs/constants.ts'; + +/** + * The seven-character status codes carried in a receipt's `stat:` field, mapped to the + * message_state values they correspond to. + */ +const receiptStates: Record = { + ACCEPTD: 'ACCEPTED', + DELETED: 'DELETED', + DELIVRD: 'DELIVERED', + ENROUTE: 'ENROUTE', + EXPIRED: 'EXPIRED', + REJECTD: 'REJECTED', + UNDELIV: 'UNDELIVERABLE', + UNKNOWN: 'UNKNOWN', +}; + +/** message_state values as the seven-character codes a receipt's `stat:` field must carry. */ +export const receiptCodes: Record = { + ACCEPTED: 'ACCEPTD', + DELETED: 'DELETED', + DELIVERED: 'DELIVRD', + ENROUTE: 'ENROUTE', + EXPIRED: 'EXPIRED', + REJECTED: 'REJECTD', + SCHEDULED: 'ENROUTE', + SKIPPED: 'UNKNOWN', + UNDELIVERABLE: 'UNDELIV', + UNKNOWN: 'UNKNOWN', +}; + +export type Receipt = { + doneDate: string | undefined; + dlvrd: number | undefined; + err: string | undefined; + id: string | undefined; + stat: string | undefined; + sub: number | undefined; + submitDate: string | undefined; + text: string | undefined; +}; + +export type Dlr = { + doneDate: Date | undefined; + errorCode: string | undefined; + receipt: Receipt | undefined; + smsId: string; + statusId: number; + statusMsg: string; +}; + +const field = (name: string) => new RegExp(`\\b${name}:([^ ]*)`, 'i'); + +const patterns = { + dlvrd: field('dlvrd'), + doneDate: /\bdone date:([^ ]*)/i, + err: field('err'), + id: field('id'), + stat: field('stat'), + sub: field('sub'), + submitDate: /\bsubmit date:([^ ]*)/i, + text: /\btext:(.*)$/i, +}; + +function toNumber(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** Delivery receipt dates are YYMMDDhhmm, sometimes with seconds. */ +function receiptDate(value: string | undefined): Date | undefined { + if (value === undefined) return undefined; + + const match = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)?$/.exec(value); + + if (!match) return undefined; + + const [, years, months, days, hours, minutes, seconds] = match; + const century = Math.floor(new Date().getUTCFullYear() / 100) * 100; + + return new Date(Date.UTC( + century + Number(years), + Number(months) - 1, + Number(days), + Number(hours), + Number(minutes), + Number(seconds ?? 0), + )); +} + +/** + * Parses the standard receipt body, as in + * `id:0123 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:…` + */ +export function parseReceipt(message: string): Receipt { + const read = (pattern: RegExp): string | undefined => pattern.exec(message)?.[1]; + + return { + dlvrd: toNumber(read(patterns.dlvrd)), + doneDate: read(patterns.doneDate), + err: read(patterns.err), + id: read(patterns.id), + stat: read(patterns.stat), + sub: toNumber(read(patterns.sub)), + submitDate: read(patterns.submitDate), + text: read(patterns.text), + }; +} + +/** + * Builds a delivery report from a deliver_sm. The message_state and receipted_message_id TLVs are + * authoritative when present; otherwise the receipt text is parsed, which is the only thing Kannel + * and several other SMSCs send. 0.4.0 required the TLVs and rejected everything else. + */ +export function dlrFromPdu(pduObj: PduObject): Dlr | undefined { + const message = pduObj.params.short_message; + const receipt = typeof message === 'string' ? parseReceipt(message) : undefined; + + const tlvState = pduObj.tlvs.message_state?.tagValue; + 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] + : receiptStates[receipt?.stat?.toUpperCase() ?? '']; + + if (statusMsg === undefined) return undefined; + + const statusId = typeof tlvState === 'number' + ? tlvState + : consts.MESSAGE_STATE[receiptStates[receipt?.stat?.toUpperCase() ?? ''] ?? 'UNKNOWN']; + + return { + doneDate: receiptDate(receipt?.doneDate), + errorCode: receipt?.err, + receipt, + smsId, + statusId, + statusMsg, + }; +} diff --git a/src/pdu-framer.ts b/src/pdu-framer.ts new file mode 100644 index 0000000..c4d49d0 --- /dev/null +++ b/src/pdu-framer.ts @@ -0,0 +1,71 @@ +import type { Result } from './result.ts'; +import { maxPduLength } from './pdu.ts'; + +/** + * Cuts a byte stream into whole PDUs. + * + * Chunks are held in a list and only joined when a complete PDU is available, so a peer dribbling + * bytes cannot make this quadratic the way concatenating the whole queue on every chunk does. + */ +export class PduFramer { + private chunks: Buffer[] = []; + private length = 0; + + get buffered(): number { + return this.length; + } + + push(chunk: Buffer): void { + if (chunk.length === 0) return; + + this.chunks.push(chunk); + this.length += chunk.length; + } + + /** + * Every complete PDU buffered so far. An error means the stream is unusable — the peer sent a + * command length that cannot be honoured — and the caller should close the connection. + */ + next(): Result<{ pdus: Buffer[] }> { + const pdus: Buffer[] = []; + + while (this.length >= 16) { + const cmdLength = this.join(16).readUInt32BE(0); + + if (cmdLength < 16 || cmdLength > maxPduLength) { + return { err: new Error(`Refusing a cmd_length of ${String(cmdLength)}`) }; + } + + if (this.length < cmdLength) break; + + pdus.push(this.take(cmdLength)); + } + + return { pdus }; + } + + /** Makes sure the first chunk holds at least `size` octets, then returns it. */ + private join(size: number): Buffer { + const first = this.chunks[0]; + + if (first && first.length >= size) return first; + + const joined = Buffer.concat(this.chunks, this.length); + + this.chunks = [joined]; + + return joined; + } + + private take(size: number): Buffer { + const source = this.join(size); + const pdu = source.subarray(0, size); + const rest = source.subarray(size); + + this.chunks[0] = rest; + if (rest.length === 0) this.chunks.shift(); + this.length -= size; + + return pdu; + } +} diff --git a/test/dlr.test.ts b/test/dlr.test.ts new file mode 100644 index 0000000..b0fbc26 --- /dev/null +++ b/test/dlr.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; +import { objToPdu, pduToObj } 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'; + +function deliverSm(message: string, tlvs?: Record): PduObject { + const { buffer } = objToPdu({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46701113311', + esm_class: 4, + short_message: message, + source_addr: '46709771337', + }, + seqNr: 1, + tlvs, + }); + + assert.ok(buffer); + + const { pduObj } = pduToObj(buffer); + + assert.ok(pduObj); + + return pduObj; +} + +describe('parseReceipt()', () => { + test('pulls every standard field out of the receipt body', () => { + const receipt = parseReceipt(receiptText); + + assert.equal(receipt.id, '0195f0c7'); + assert.equal(receipt.sub, 1); + assert.equal(receipt.dlvrd, 1); + assert.equal(receipt.submitDate, '2508251430'); + assert.equal(receipt.doneDate, '2508251431'); + assert.equal(receipt.stat, 'DELIVRD'); + assert.equal(receipt.err, '000'); + assert.equal(receipt.text, 'hello'); + }); + + test('does not confuse "done date" with "submit date"', () => { + const receipt = parseReceipt(receiptText); + + assert.notEqual(receipt.submitDate, receipt.doneDate); + }); + + test('leaves absent fields undefined rather than guessing', () => { + const receipt = parseReceipt('id:abc stat:UNDELIV'); + + assert.equal(receipt.id, 'abc'); + assert.equal(receipt.stat, 'UNDELIV'); + assert.equal(receipt.sub, undefined); + assert.equal(receipt.doneDate, undefined); + }); +}); + +describe('dlrFromPdu()', () => { + test('prefers the TLVs when the peer sends them', () => { + const dlr = dlrFromPdu(deliverSm(receiptText, { + message_state: { tagId: 0x0427, tagValue: 5 }, + receipted_message_id: { tagId: 0x001E, tagValue: 'from-the-tlv' }, + })); + + assert.ok(dlr); + assert.equal(dlr.smsId, 'from-the-tlv'); + assert.equal(dlr.statusId, 5); + assert.equal(dlr.statusMsg, 'UNDELIVERABLE'); + }); + + // 0.4.0 answered ESME_RINVTLVSTREAM unless both TLVs were present, so Kannel-style receipts — + // text only, no TLVs — were unusable. + test('falls back to the receipt text when there are no TLVs', () => { + const dlr = dlrFromPdu(deliverSm(receiptText)); + + assert.ok(dlr); + assert.equal(dlr.smsId, '0195f0c7'); + assert.equal(dlr.statusMsg, 'DELIVERED'); + assert.equal(dlr.statusId, 2); + assert.equal(dlr.errorCode, '000'); + assert.equal(dlr.doneDate?.toISOString(), '2025-08-25T14:31:00.000Z'); + }); + + test('maps every spec status code back to its message state', () => { + for (const [code, expected] of [ + ['DELIVRD', 'DELIVERED'], + ['UNDELIV', 'UNDELIVERABLE'], + ['EXPIRED', 'EXPIRED'], + ['DELETED', 'DELETED'], + ['ACCEPTD', 'ACCEPTED'], + ['REJECTD', 'REJECTED'], + ['ENROUTE', 'ENROUTE'], + ['UNKNOWN', 'UNKNOWN'], + ]) { + const dlr = dlrFromPdu(deliverSm(`id:x stat:${String(code)} err:0`)); + + assert.equal(dlr?.statusMsg, expected); + } + }); + + test('returns nothing when the PDU identifies no message', () => { + assert.equal(dlrFromPdu(deliverSm('just a normal sms')), undefined); + }); + + test('exposes the raw receipt alongside the resolved fields', () => { + const dlr = dlrFromPdu(deliverSm(receiptText)); + + assert.ok(dlr?.receipt); + assert.equal(dlr.receipt.sub, 1); + assert.equal(dlr.receipt.text, 'hello'); + }); +}); + +describe('receiptCodes', () => { + // 0.4.0 wrote stat:UNDELIVERABLE, which is not the spec's seven-character field. + test('are the seven-character codes the spec defines', () => { + assert.equal(receiptCodes.DELIVERED, 'DELIVRD'); + assert.equal(receiptCodes.UNDELIVERABLE, 'UNDELIV'); + + for (const code of Object.values(receiptCodes)) { + assert.equal(code.length, 7); + } + }); +}); diff --git a/test/pdu-framer.test.ts b/test/pdu-framer.test.ts new file mode 100644 index 0000000..1461b43 --- /dev/null +++ b/test/pdu-framer.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { PduFramer } from '../src/pdu-framer.ts'; +import { objToPdu } from '../src/pdu.ts'; + +function pdu(seqNr: number): Buffer { + const { buffer } = objToPdu({ cmdName: 'enquire_link', seqNr }); + + assert.ok(buffer); + + return buffer; +} + +describe('PduFramer', () => { + test('yields nothing until a whole PDU has arrived', () => { + const framer = new PduFramer(); + const whole = pdu(1); + + framer.push(whole.subarray(0, 8)); + assert.deepEqual(framer.next(), { pdus: [] }); + + framer.push(whole.subarray(8)); + assert.deepEqual(framer.next(), { pdus: [whole] }); + }); + + test('splits several PDUs delivered in one chunk', () => { + const framer = new PduFramer(); + + framer.push(Buffer.concat([pdu(1), pdu(2), pdu(3)])); + + const { pdus } = framer.next(); + + assert.ok(pdus); + assert.equal(pdus.length, 3); + assert.deepEqual(pdus[2], pdu(3)); + }); + + test('reassembles a PDU dribbled one octet at a time', () => { + const framer = new PduFramer(); + const whole = pdu(42); + + for (const octet of whole) { + framer.push(Buffer.from([octet])); + } + + assert.deepEqual(framer.next(), { pdus: [whole] }); + assert.equal(framer.buffered, 0); + }); + + test('keeps a trailing partial PDU buffered for the next chunk', () => { + const framer = new PduFramer(); + const second = pdu(2); + + framer.push(Buffer.concat([pdu(1), second.subarray(0, 4)])); + + const first = framer.next(); + + assert.ok(first.pdus); + assert.equal(first.pdus.length, 1); + assert.equal(framer.buffered, 4); + + framer.push(second.subarray(4)); + assert.deepEqual(framer.next(), { pdus: [second] }); + }); + + // 0.4.0 read a command length of 0 as "discard the buffer" and looped; anything absurd was + // simply trusted and allocated. + test('reports an impossible command length instead of trusting it', () => { + const zero = new PduFramer(); + const huge = new PduFramer(); + + zero.push(Buffer.alloc(16)); + assert.ok(zero.next().err instanceof Error); + + huge.push(Buffer.from('ffffffff0000001500000000000000ff', 'hex')); + assert.ok(huge.next().err instanceof Error); + }); + + test('ignores empty chunks', () => { + const framer = new PduFramer(); + + framer.push(Buffer.alloc(0)); + + assert.equal(framer.buffered, 0); + assert.deepEqual(framer.next(), { pdus: [] }); + }); +});