Add PDU framing and delivery receipt parsing
This commit is contained in:
+151
@@ -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<string, MessageState> = {
|
||||
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<MessageState, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, TlvInput>): 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user