Read a delivery receipt's body as the octets that arrived (#81)
* Regression tests for a receipt body read under a data_coding it is not written in * Read a delivery receipt's body as the octets that arrived, not by its data_coding * Assert SMPPSim's UCS2 receipt body parses like every other segment's * Record the receipt-body defect as fixed * Retain a multipart segment's octets once, not twice * Sort the new type import into its file's order
This commit is contained in:
@@ -87,6 +87,7 @@ src/
|
||||
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry
|
||||
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
|
||||
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
|
||||
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
|
||||
pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
|
||||
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
|
||||
reassembly.ts Reassembler: capped, expiring multipart groups
|
||||
@@ -96,7 +97,7 @@ src/
|
||||
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, and their reference
|
||||
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
|
||||
unanswered-error.ts UnansweredError: it went out and no answer came back
|
||||
uuid.ts uuidv7() — the ids the library generates for messages
|
||||
defs/
|
||||
@@ -289,6 +290,19 @@ Grouped by what each one constrains.
|
||||
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
|
||||
the body.
|
||||
|
||||
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
|
||||
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
|
||||
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
|
||||
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
|
||||
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
|
||||
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
|
||||
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
|
||||
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
|
||||
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
|
||||
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
|
||||
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
|
||||
but `data_coding` can say how a message was written.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -137,8 +137,9 @@ session.on('sms', async sms => {
|
||||
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
|
||||
`receipted_message_id` TLV does, and failing both the message body is read for the standard
|
||||
`id:` and `stat:` receipt fields. An intermediate delivery notification is the SMSC reporting as
|
||||
well, not an inbound message.
|
||||
`id:` and `stat:` receipt fields. That body is read as text whatever `data_coding` the receipt
|
||||
declares, since SMSCs commonly copy the reported message's onto it. 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()`
|
||||
returned. Some SMSCs write the two in different notations — a hex `message_id` on the
|
||||
@@ -383,6 +384,9 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||||
}
|
||||
```
|
||||
|
||||
`params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
|
||||
same field exactly as it arrived.
|
||||
|
||||
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ tests passing, `malformed: 0`, `expert errors: 0` in both.
|
||||
| C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) |
|
||||
| C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link |
|
||||
| C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] |
|
||||
| C7 (loopback, UCS2 2-segment) | pass with a documented defect | `… 2-segment UCS2 …: TLVs stay right, the receipt body is corrupted`; TLVs and loopback reassembly both correct, receipt body unreadable - `@larvit/smpp` defect below |
|
||||
| C7 (loopback, UCS2 2-segment) | pass with a defect, since fixed | `… 2-segment UCS2 …`; TLVs and loopback reassembly both correct, receipt body unreadable at this commit - `@larvit/smpp` defect below |
|
||||
| C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows |
|
||||
| C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains |
|
||||
| C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost |
|
||||
@@ -96,6 +96,8 @@ left to fall back on. Not reproduced against `smppsim-textdlr` here (that varian
|
||||
only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see
|
||||
Open questions.
|
||||
|
||||
**Fixed** in PR #81: a receipt body is read as the octets that arrived, never by its `data_coding`.
|
||||
|
||||
### `reconnect` never retries the very first connect or bind attempt
|
||||
|
||||
**What happened.** `client()` performs the socket connect and the initial bind directly, not
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Dlr } from '../src/dlr.ts';
|
||||
import type { EncodingName } from '../src/defs/encodings.ts';
|
||||
import type { MessageDlr } from '../src/dlr-merger.ts';
|
||||
import type { PduObject } from '../src/pdu.ts';
|
||||
import type { SendSmsResult } from '../src/send-sms.ts';
|
||||
import type { Session } from '../src/session.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
@@ -164,11 +163,20 @@ async function sendUntilComplete(
|
||||
}
|
||||
|
||||
describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||||
const cases: { expectedSegments: number; label: string; message: string }[] = [
|
||||
const cases: { encoding?: EncodingName; expectedSegments: number; label: string; message: string }[] = [
|
||||
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: gsmFiller(100) },
|
||||
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) },
|
||||
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) },
|
||||
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: gsmFiller(1450) },
|
||||
// SMPPSim writes this one's receipt body as text under the data_coding of the submit it
|
||||
// reports on (8, UCS2), so it holds to the same bar as the GSM cases only if the body is
|
||||
// read as the octets that arrived.
|
||||
{
|
||||
encoding: 'UCS2',
|
||||
expectedSegments: 2,
|
||||
label: '2-segment UCS2 with 一 and an emoji',
|
||||
message: `一😀${'x'.repeat(70)}`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
@@ -182,7 +190,13 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||||
const dlrs = collectDlrs(session);
|
||||
const sms = collectSms(session);
|
||||
|
||||
const { reassembled, smsIds } = await sendUntilComplete(session, dlrs, sms, testCase.message);
|
||||
const { reassembled, smsIds } = await sendUntilComplete(
|
||||
session,
|
||||
dlrs,
|
||||
sms,
|
||||
testCase.message,
|
||||
testCase.encoding,
|
||||
);
|
||||
|
||||
assert.equal(smsIds.length, testCase.expectedSegments);
|
||||
|
||||
@@ -195,75 +209,15 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||||
// match, which is what a well-behaved SMSC gives you (C3).
|
||||
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
|
||||
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
|
||||
assert.equal(received.dlr.receipt?.stat, 'DELIVRD');
|
||||
assert.ok(received.dlr.receipt);
|
||||
assert.equal(received.dlr.receipt.stat, 'DELIVRD');
|
||||
assert.equal(received.dlr.receipt.id, id);
|
||||
assert.equal(received.dlr.receipt.err, '000');
|
||||
}
|
||||
|
||||
assert.equal((await reassembled.sendResp()).err, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('2-segment UCS2 with 一 and an emoji: TLVs stay right, the receipt body is corrupted', async t => {
|
||||
const { err, session } = await bind(PEER_HOST);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const dlrs = collectDlrs(session);
|
||||
const sms = collectSms(session);
|
||||
const message = `一😀${'x'.repeat(70)}`;
|
||||
|
||||
let smsIds: string[] | undefined;
|
||||
let reassembled: Sms | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !reassembled; attempt++) {
|
||||
const sent: SendSmsResult = await session.sendSms(
|
||||
{ dlr: true, encoding: 'UCS2', from: FROM, message, to: TO },
|
||||
);
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.equal(sent.smsIds.length, 2);
|
||||
|
||||
const complete = await waitFor(() => {
|
||||
const tlvsIntact = sent.smsIds.every(id => {
|
||||
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||
|
||||
return received?.pduObj.tlvs.receipted_message_id?.tagValue !== undefined
|
||||
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
||||
});
|
||||
const found = sms.find(s => s.message === message);
|
||||
|
||||
return tlvsIntact && found ? { found } : undefined;
|
||||
}, DLR_RETRY_BUDGET_MS);
|
||||
|
||||
if (complete) {
|
||||
smsIds = sent.smsIds;
|
||||
reassembled = complete.found;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(smsIds);
|
||||
assert.ok(reassembled, 'expected the loopback deliver_sm(s), unaffected by the defect, to reassemble');
|
||||
|
||||
for (const id of smsIds) {
|
||||
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||
|
||||
assert.ok(received);
|
||||
// The TLVs are typed fields, unaffected by the defect below.
|
||||
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
|
||||
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
|
||||
|
||||
// Defect (see findings/02-smppsim.md): SMPPSim's delivery receipt echoes the
|
||||
// original submit_sm's data_coding (8, UCS2) but writes its short_message as plain
|
||||
// ASCII text. pdu.ts decodes short_message for any non-UDH PDU using that same
|
||||
// data_coding at parse time, so the ASCII receipt bytes are read back as UCS2 -
|
||||
// every "stat:"/"id:" field becomes unrecoverable CJK-range garbage.
|
||||
assert.equal(received.dlr.receipt?.stat, undefined);
|
||||
}
|
||||
|
||||
assert.equal((await reassembled.sendResp()).err, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smppsim-textdlr - C2 text-only receipts', () => {
|
||||
|
||||
@@ -265,3 +265,12 @@ export function commandNameById(id: number): CommandName | undefined {
|
||||
|
||||
return isCommandName(command) ? command : undefined;
|
||||
}
|
||||
|
||||
/** The response SMPP pairs with a request command, where it has one. */
|
||||
export function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
|
||||
if (cmdName === undefined) return undefined;
|
||||
|
||||
const respName = `${cmdName}_resp`;
|
||||
|
||||
return isCommandName(respName) ? respName : undefined;
|
||||
}
|
||||
|
||||
+11
-10
@@ -2,10 +2,11 @@ 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 { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
||||
import { encodings } from './defs/encodings.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramNumber, paramText } from './defs/types.ts';
|
||||
import { udhLength } from './udh.ts';
|
||||
|
||||
/**
|
||||
* The seven-character status codes carried in a receipt's `stat:` field, mapped to the
|
||||
@@ -154,17 +155,17 @@ function messageType(pduObj: PduObject): MessageType {
|
||||
return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt';
|
||||
}
|
||||
|
||||
/** A UDH-carrying short_message reaches here as a buffer, header and all. */
|
||||
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
|
||||
function receiptBody(pduObj: PduObject): string {
|
||||
const message = pduObj.params.short_message;
|
||||
const octets = pduObj.shortMessageOctets;
|
||||
|
||||
if (!Buffer.isBuffer(message)) return paramText(message);
|
||||
if (octets === undefined) return paramText(pduObj.params.short_message);
|
||||
|
||||
return decodeMessage(
|
||||
message,
|
||||
paramNumber(pduObj.params.data_coding, 0),
|
||||
paramNumber(pduObj.params.esm_class, 0),
|
||||
).message;
|
||||
const body = hasUdh(paramNumber(pduObj.params.esm_class, 0))
|
||||
? octets.subarray(udhLength(octets))
|
||||
: octets;
|
||||
|
||||
return encodings.LATIN1.decode(body);
|
||||
}
|
||||
|
||||
function receiptId(
|
||||
|
||||
+2
-1
@@ -12,13 +12,14 @@ export { types } from './defs/types.ts';
|
||||
export {
|
||||
isCommand,
|
||||
isResp,
|
||||
maxPduLength,
|
||||
maxSeqNr,
|
||||
objToPdu,
|
||||
pduReturn,
|
||||
pduToObj,
|
||||
} from './pdu.ts';
|
||||
|
||||
export { maxPduLength } from './pdu-refusal.ts';
|
||||
|
||||
export {
|
||||
bitCount,
|
||||
decodeMessage,
|
||||
|
||||
+5
-4
@@ -1,7 +1,8 @@
|
||||
import type { Result } from './result.ts';
|
||||
import type { EncodingName } from './defs/encodings.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { udhLength } from './udh.ts';
|
||||
|
||||
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
||||
const singleMessageBits = 1120;
|
||||
@@ -37,11 +38,11 @@ export function decodeMessage(
|
||||
return { message: encodings[encoding].decode(buffer), udh: undefined };
|
||||
}
|
||||
|
||||
const udhLength = (buffer[0] ?? 0) + 1;
|
||||
const headerLength = udhLength(buffer);
|
||||
|
||||
return {
|
||||
message: encodings[encoding].decode(buffer.subarray(udhLength)),
|
||||
udh: buffer.subarray(0, udhLength),
|
||||
message: encodings[encoding].decode(buffer.subarray(headerLength)),
|
||||
udh: buffer.subarray(0, headerLength),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { Result } from './result.ts';
|
||||
import { framingRefusal } from './pdu.ts';
|
||||
import { framingRefusal } from './pdu-refusal.ts';
|
||||
|
||||
/**
|
||||
* Cuts a byte stream into whole PDUs.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CommandName } from './defs/commands.ts';
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import { respNameFor } from './defs/commands.ts';
|
||||
|
||||
/** A hostile peer must not be able to make us allocate arbitrarily. */
|
||||
export const maxPduLength = 1024 * 1024;
|
||||
|
||||
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
|
||||
export type PduHeader = {
|
||||
cmdId: number;
|
||||
cmdLength: number;
|
||||
cmdName: CommandName | undefined;
|
||||
cmdStatusId: number;
|
||||
seqNr: number;
|
||||
};
|
||||
|
||||
/** Which part of a PDU the codec could not read. */
|
||||
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
|
||||
|
||||
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
|
||||
export class PduRefusedError extends Error {
|
||||
readonly header: PduHeader;
|
||||
readonly reason: PduRefusalReason;
|
||||
|
||||
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
|
||||
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
|
||||
|
||||
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
|
||||
this.header = header;
|
||||
this.name = 'PduRefusedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
|
||||
const refusalStatus = {
|
||||
body: 'ESME_RINVCMDLEN',
|
||||
command: 'ESME_RINVCMDID',
|
||||
tlvs: 'ESME_RINVTLVSTREAM',
|
||||
} as const satisfies Record<PduRefusalReason, ErrorName>;
|
||||
|
||||
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
|
||||
export function framingRefusal(cmdLength: number): Error | undefined {
|
||||
if (cmdLength < 16 || cmdLength > maxPduLength) {
|
||||
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
|
||||
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
|
||||
return {
|
||||
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
|
||||
cmdStatus: refusalStatus[refused.reason],
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import type { SmppLog } from './log.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import type { VoidResult } from './result.ts';
|
||||
import { PduFramer } from './pdu-framer.ts';
|
||||
import { PduRefusedError, pduToObj } from './pdu.ts';
|
||||
import { PduRefusedError } from './pdu-refusal.ts';
|
||||
import { pduToObj } from './pdu.ts';
|
||||
|
||||
export type PduTransportOptions = {
|
||||
log: SmppLog;
|
||||
|
||||
+9
-67
@@ -1,9 +1,11 @@
|
||||
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduHeader } from './pdu-refusal.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Tlv } from './defs/tlvs.ts';
|
||||
import { cmds, commandNameById, isCommandName } from './defs/commands.ts';
|
||||
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
||||
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
||||
import { consts, hasUdh } from './defs/constants.ts';
|
||||
import { decodeMessage, encodeMessage } from './message.ts';
|
||||
import { detect, encodingByDataCoding } from './defs/encodings.ts';
|
||||
@@ -17,18 +19,6 @@ export const maxSeqNr = 2147483646;
|
||||
/** What the field holds. Read and echoed in full, because peers do write above the spec's range. */
|
||||
const maxWireSeqNr = 0xFFFFFFFF;
|
||||
|
||||
/** A hostile peer must not be able to make us allocate arbitrarily. */
|
||||
export const maxPduLength = 1024 * 1024;
|
||||
|
||||
/** Why a command_length cannot frame a stream: past it nothing can say where the next PDU starts. */
|
||||
export function framingRefusal(cmdLength: number): Error | undefined {
|
||||
if (cmdLength < 16 || cmdLength > maxPduLength) {
|
||||
return new Error(`Refusing a cmd_length of ${String(cmdLength)}`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type TlvInput = {
|
||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
||||
tagId?: number | undefined;
|
||||
@@ -55,60 +45,11 @@ export type PduObject = {
|
||||
cmdStatusId: number;
|
||||
params: Record<string, ParamValue>;
|
||||
seqNr: number;
|
||||
/** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
|
||||
shortMessageOctets: Buffer | undefined;
|
||||
tlvs: Record<string, Tlv>;
|
||||
};
|
||||
|
||||
/** The 16 octets a framed PDU always has, whatever its body turns out to be. */
|
||||
export type PduHeader = {
|
||||
cmdId: number;
|
||||
cmdLength: number;
|
||||
cmdName: CommandName | undefined;
|
||||
cmdStatusId: number;
|
||||
seqNr: number;
|
||||
};
|
||||
|
||||
/** Which part of a PDU the codec could not read. */
|
||||
export type PduRefusalReason = 'body' | 'command' | 'tlvs';
|
||||
|
||||
/** A PDU refused with the stream still in sync, so only this one PDU is lost. */
|
||||
export class PduRefusedError extends Error {
|
||||
readonly header: PduHeader;
|
||||
readonly reason: PduRefusalReason;
|
||||
|
||||
constructor(header: PduHeader, reason: PduRefusalReason, cause: Error) {
|
||||
const named = header.cmdName ?? `command id ${String(header.cmdId)}`;
|
||||
|
||||
super(`Refused ${named} with seqNr ${String(header.seqNr)}: ${cause.message}`, { cause });
|
||||
this.header = header;
|
||||
this.name = 'PduRefusedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ESME_RINVTLVSTREAM is SMPP 5.0's name for 0xC0, which SMPP 3.4 spells ESME_RINVOPTPARSTREAM.
|
||||
const refusalStatus = {
|
||||
body: 'ESME_RINVCMDLEN',
|
||||
command: 'ESME_RINVCMDID',
|
||||
tlvs: 'ESME_RINVTLVSTREAM',
|
||||
} as const satisfies Record<PduRefusalReason, ErrorName>;
|
||||
|
||||
/** The response SMPP pairs with a request command, where it has one. */
|
||||
function respNameFor(cmdName: CommandName | undefined): CommandName | undefined {
|
||||
if (cmdName === undefined) return undefined;
|
||||
|
||||
const respName = `${cmdName}_resp`;
|
||||
|
||||
return isCommandName(respName) ? respName : undefined;
|
||||
}
|
||||
|
||||
/** SMPP 3.4 4.3: a PDU whose command has no response of its own is refused with generic_nack. */
|
||||
export function refusalAnswer(refused: PduRefusedError): { cmdName: CommandName; cmdStatus: ErrorName } {
|
||||
return {
|
||||
cmdName: respNameFor(refused.header.cmdName) ?? 'generic_nack',
|
||||
cmdStatus: refusalStatus[refused.reason],
|
||||
};
|
||||
}
|
||||
|
||||
const respBit = 0x80000000;
|
||||
|
||||
export function isResp(pduObj: Pick<PduObject, 'cmdId'>): boolean {
|
||||
@@ -391,11 +332,11 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
|
||||
|
||||
const params = read.params;
|
||||
const message = params.short_message;
|
||||
const esmClass = paramNumber(params.esm_class, 0);
|
||||
const octets = Buffer.isBuffer(message) ? message : undefined;
|
||||
|
||||
// A message carrying a UDH stays a buffer; the session needs the header intact to reassemble.
|
||||
if (Buffer.isBuffer(message) && !hasUdh(esmClass)) {
|
||||
params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message;
|
||||
if (octets && !hasUdh(paramNumber(params.esm_class, 0))) {
|
||||
params.short_message = decodeMessage(octets, paramNumber(params.data_coding, 0)).message;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -408,6 +349,7 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
|
||||
cmdStatusId,
|
||||
params,
|
||||
seqNr,
|
||||
shortMessageOctets: octets,
|
||||
tlvs: parsed.tlvs,
|
||||
},
|
||||
};
|
||||
|
||||
+6
-1
@@ -39,7 +39,12 @@ function detach(pduObj: PduObject): PduObject {
|
||||
: tlv;
|
||||
}
|
||||
|
||||
return { ...pduObj, params, tlvs };
|
||||
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
|
||||
const octets = Buffer.isBuffer(params.short_message)
|
||||
? params.short_message
|
||||
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
|
||||
|
||||
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
|
||||
}
|
||||
|
||||
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
|
||||
|
||||
+4
-2
@@ -1,7 +1,8 @@
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { MessageDlr } from './dlr-merger.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject, PduObjectInput, PduRefusedError, TlvInput } from './pdu.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
import type { PduRefusedError } from './pdu-refusal.ts';
|
||||
import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||
@@ -18,7 +19,8 @@ import { leftOf } from './idle-waiters.ts';
|
||||
import { errorFrom } from './error-from.ts';
|
||||
import { optionalParamsMinVersion } from './defs/constants.ts';
|
||||
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
|
||||
import { isResp, objToPdu, pduReturn, refusalAnswer } from './pdu.ts';
|
||||
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
||||
import { refusalAnswer } from './pdu-refusal.ts';
|
||||
import { guardedLog } from './log.ts';
|
||||
import { submitSms, unsent } from './send-sms.ts';
|
||||
import { ConcatReference } from './udh.ts';
|
||||
|
||||
@@ -9,6 +9,11 @@ export class ConcatReference {
|
||||
}
|
||||
}
|
||||
|
||||
/** A user data header is as long as its first octet says, that octet included. */
|
||||
export function udhLength(message: Buffer): number {
|
||||
return (message[0] ?? 0) + 1;
|
||||
}
|
||||
|
||||
export type ConcatInfo = {
|
||||
part: number;
|
||||
reference: number;
|
||||
|
||||
+72
-2
@@ -2,19 +2,26 @@ import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
|
||||
import { encodeMessage } from '../src/message.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 there';
|
||||
|
||||
/** SMPPSim writes this body as plain text under the data_coding of the message it reports on. */
|
||||
const textReceiptId = '01a072f9-30f0-7807-945f-3412d4d5b8c3';
|
||||
const textReceipt = `id:${textReceiptId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 Text:hello there`;
|
||||
|
||||
function deliverSm(
|
||||
message: Buffer | string,
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
dataCoding = 0,
|
||||
): PduObject {
|
||||
const { buffer } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
data_coding: dataCoding,
|
||||
destination_addr: '46701113311',
|
||||
esm_class: esmClass,
|
||||
short_message: message,
|
||||
@@ -149,8 +156,8 @@ describe('dlrFromPdu()', () => {
|
||||
assert.equal(dlr.intermediate, false);
|
||||
});
|
||||
|
||||
// pduToObj leaves a UDH-carrying short_message a buffer, so the body needs decoding before it
|
||||
// can be read at all — and the message type sits under the UDH indicator in the same octet.
|
||||
// The message type sits under the UDH indicator in the same octet, and the header has to come
|
||||
// off the body before any of it can be read.
|
||||
test('reads the body of a receipt that carries a UDH', () => {
|
||||
const udh = Buffer.from([0x05, 0x00, 0x03, 0x2a, 0x01, 0x01]);
|
||||
const body = Buffer.concat([udh, Buffer.from(receiptText, 'ascii')]);
|
||||
@@ -158,6 +165,7 @@ describe('dlrFromPdu()', () => {
|
||||
body,
|
||||
undefined,
|
||||
consts.ESM_CLASS.MC_DELIVERY_RECEIPT | consts.ESM_CLASS.UDH_INDICATOR,
|
||||
consts.ENCODING.UCS2,
|
||||
));
|
||||
|
||||
assert.ok(dlr);
|
||||
@@ -165,6 +173,68 @@ describe('dlrFromPdu()', () => {
|
||||
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||
});
|
||||
|
||||
test('reads a text receipt out of a PDU whose data_coding declares UCS2', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(
|
||||
Buffer.from(textReceipt, 'ascii'),
|
||||
{
|
||||
message_state: { tagValue: consts.MESSAGE_STATE.DELIVERED },
|
||||
receipted_message_id: { tagValue: textReceiptId },
|
||||
},
|
||||
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
consts.ENCODING.UCS2,
|
||||
));
|
||||
|
||||
assert.ok(dlr?.receipt);
|
||||
assert.equal(dlr.receipt.id, textReceiptId);
|
||||
assert.equal(dlr.receipt.sub, 1);
|
||||
assert.equal(dlr.receipt.dlvrd, 1);
|
||||
assert.equal(dlr.receipt.submitDate, '2509051430');
|
||||
assert.equal(dlr.receipt.doneDate, '2509051431');
|
||||
assert.equal(dlr.receipt.stat, 'DELIVRD');
|
||||
assert.equal(dlr.receipt.err, '000');
|
||||
assert.equal(dlr.receipt.text, 'hello there');
|
||||
assert.equal(dlr.smsId, textReceiptId);
|
||||
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||
});
|
||||
|
||||
test('reads that same receipt with no TLVs to fall back on', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(
|
||||
Buffer.from(textReceipt, 'ascii'),
|
||||
undefined,
|
||||
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
consts.ENCODING.UCS2,
|
||||
));
|
||||
|
||||
assert.ok(dlr);
|
||||
assert.equal(dlr.smsId, textReceiptId);
|
||||
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||
assert.equal(dlr.statusId, consts.MESSAGE_STATE.DELIVERED);
|
||||
});
|
||||
|
||||
// A receipt a peer really did write in UCS2 costs exactly this: undetermined, never guessed at.
|
||||
test('reports a receipt body it cannot read either way as undetermined', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(
|
||||
encodeMessage(`id:${textReceiptId} stat:DELIVRD err:000`, 'UCS2').buffer,
|
||||
undefined,
|
||||
consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
consts.ENCODING.UCS2,
|
||||
));
|
||||
|
||||
assert.ok(dlr);
|
||||
assert.equal(dlr.receipt?.id, undefined);
|
||||
assert.equal(dlr.receipt?.stat, undefined);
|
||||
assert.equal(dlr.smsId, undefined);
|
||||
assert.equal(dlr.statusMsg, 'UNKNOWN');
|
||||
assert.equal(dlr.statusId, consts.MESSAGE_STATE.UNKNOWN);
|
||||
});
|
||||
|
||||
test('leaves a UCS2 message to arrive as an SMS, decoded by its data_coding', () => {
|
||||
const pduObj = deliverSm(encodeMessage('hej 一', 'UCS2').buffer, undefined, 0, consts.ENCODING.UCS2);
|
||||
|
||||
assert.equal(dlrFromPdu(pduObj), undefined);
|
||||
assert.equal(pduObj.params.short_message, 'hej 一');
|
||||
});
|
||||
|
||||
// message_state 0x80-0xFF is reserved for MC-vendor-specific values, which we cannot name.
|
||||
test('falls back to the body when the state TLV carries a value it cannot name', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(receiptText, { message_state: { tagValue: 0x84 } }));
|
||||
|
||||
+37
-1
@@ -1,6 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import { PduRefusedError, isCommand, isResp, objToPdu, pduReturn, pduToObj, refusalAnswer } from '../src/pdu.ts';
|
||||
import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
|
||||
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
||||
import { paramText } from '../src/defs/types.ts';
|
||||
|
||||
function encode(...args: Parameters<typeof objToPdu>): Buffer {
|
||||
const { buffer, err } = objToPdu(...args);
|
||||
@@ -206,6 +208,40 @@ describe('encoding submit_sm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('decoding short_message', () => {
|
||||
function deliverSm(dataCoding: number, message: Buffer | string) {
|
||||
return decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
data_coding: dataCoding,
|
||||
destination_addr: '46709771337',
|
||||
short_message: message,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 3,
|
||||
}));
|
||||
}
|
||||
|
||||
test('reads an inbound message with the data_coding the PDU declares', () => {
|
||||
const binary = Buffer.from([0x00, 0x1B, 0x60, 0x80, 0xFF]);
|
||||
|
||||
assert.equal(deliverSm(0x08, 'hej 一').params.short_message, 'hej 一');
|
||||
assert.equal(deliverSm(0x03, Buffer.from([0xE1, 0xE7, 0xDA])).params.short_message, 'áçÚ');
|
||||
assert.deepEqual(
|
||||
Buffer.from(paramText(deliverSm(0x04, binary).params.short_message), 'latin1'),
|
||||
binary,
|
||||
);
|
||||
});
|
||||
|
||||
// A delivery receipt is read from these rather than from the text above, since its body is
|
||||
// Appendix B's fixed format whatever the data_coding the peer inherited onto it says.
|
||||
test('keeps the octets that arrived alongside the text they decoded to', () => {
|
||||
const pduObj = deliverSm(0x08, 'hej 一');
|
||||
|
||||
assert.deepEqual(pduObj.shortMessageOctets, Buffer.from('hej 一', 'utf16le').swap16());
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding submit_multi', () => {
|
||||
const dest = { dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46709771337' };
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject
|
||||
cmdStatusId: 0,
|
||||
params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' },
|
||||
seqNr,
|
||||
shortMessageOctets: undefined,
|
||||
tlvs: {},
|
||||
};
|
||||
}
|
||||
@@ -313,6 +314,7 @@ describe('sendSms()', () => {
|
||||
cmdStatusId: errors[status],
|
||||
params: { message_id: messageId },
|
||||
seqNr,
|
||||
shortMessageOctets: undefined,
|
||||
tlvs: {},
|
||||
};
|
||||
}
|
||||
@@ -1153,6 +1155,7 @@ describe('sendDlr()', () => {
|
||||
describe('reassembly bounds', () => {
|
||||
function segment(reference: number, part: number, total: number): PduObject {
|
||||
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
||||
const body = Buffer.concat([udh, Buffer.from('fragment')]);
|
||||
|
||||
return {
|
||||
cmdId: 0x00000004,
|
||||
@@ -1164,10 +1167,11 @@ describe('reassembly bounds', () => {
|
||||
data_coding: 0,
|
||||
destination_addr: '46709771337',
|
||||
esm_class: 0x40,
|
||||
short_message: Buffer.concat([udh, Buffer.from('fragment')]),
|
||||
short_message: body,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: part,
|
||||
shortMessageOctets: body,
|
||||
tlvs: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ function enquireLink(seqNr: number): PduObject {
|
||||
cmdStatusId: 0,
|
||||
params: {},
|
||||
seqNr,
|
||||
shortMessageOctets: undefined,
|
||||
tlvs: {},
|
||||
};
|
||||
}
|
||||
@@ -749,6 +750,35 @@ describe('receiving', () => {
|
||||
assert.equal(answeredNotification.pduObj.cmdName, 'deliver_sm_resp');
|
||||
});
|
||||
|
||||
// SMPPSim's receipt for a UCS2 message inherits its data_coding and writes the body as text.
|
||||
test('parses a receipt written as text under a data_coding that says UCS2', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const reported = once<{ dlr: Dlr; pduObj: PduObject }>(resolve => {
|
||||
session.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); });
|
||||
});
|
||||
const smsId = '01a072f9-30f2-71b0-87cd-f5032df3a8e0';
|
||||
const body = `id:${smsId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 text:`;
|
||||
const delivered = peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
data_coding: consts.ENCODING.UCS2,
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
short_message: Buffer.from(body, 'ascii'),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
});
|
||||
const received = await raceWithin(2000, reported);
|
||||
|
||||
assert.ok(received);
|
||||
assert.equal(received.dlr.smsId, smsId);
|
||||
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||
assert.equal(received.dlr.receipt?.doneDate, '2509051431');
|
||||
assert.equal(received.pduObj.params.data_coding, consts.ENCODING.UCS2);
|
||||
assert.equal(received.pduObj.shortMessageOctets?.toString('latin1'), body);
|
||||
assert.ok((await delivered).pduObj);
|
||||
});
|
||||
|
||||
test('reassembles a multipart inbound SMS before the sms event', async t => {
|
||||
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
|
||||
const { peer, session } = await inbound(t);
|
||||
|
||||
Reference in New Issue
Block a user