Read a peer's submit and receipt message ids into one notation before comparing them
This commit is contained in:
@@ -57,6 +57,7 @@ src/
|
|||||||
send-sms.ts submitSms composition and the submitSmParams builder
|
send-sms.ts submitSms composition and the submitSmParams builder
|
||||||
send-window.ts SendWindow: the maxOutstanding semaphore
|
send-window.ts SendWindow: the maxOutstanding semaphore
|
||||||
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
|
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
|
udh.ts User data header: the concatenation fields of a long SMS
|
||||||
uuid.ts uuidv7() — the ids the library generates for messages
|
uuid.ts uuidv7() — the ids the library generates for messages
|
||||||
defs/
|
defs/
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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
|
`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.
|
||||||
|
|
||||||
|
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
|
## Server
|
||||||
|
|
||||||
The simplest possible server — no authentication, listening on port 2775:
|
The simplest possible server — no authentication, listening on port 2775:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ConnectionOptions } from 'node:tls';
|
|||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { BindType } from './session-options.ts';
|
import type { BindType } from './session-options.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { SmsIdFormats } from './sms-id.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
export type { BindType };
|
export type { BindType };
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export type ClientOptions = {
|
|||||||
responseTimeout?: number;
|
responseTimeout?: number;
|
||||||
shutdownTimeout?: number;
|
shutdownTimeout?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
smsIdFormat?: SmsIdFormats;
|
||||||
systemType?: string;
|
systemType?: string;
|
||||||
tls?: ConnectionOptions | boolean;
|
tls?: ConnectionOptions | boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -147,6 +149,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
|
|||||||
maxOutstanding: options.maxOutstanding,
|
maxOutstanding: options.maxOutstanding,
|
||||||
responseTimeout: options.responseTimeout,
|
responseTimeout: options.responseTimeout,
|
||||||
shutdownTimeout: options.shutdownTimeout,
|
shutdownTimeout: options.shutdownTimeout,
|
||||||
|
smsIdFormat: options.smsIdFormat,
|
||||||
sock,
|
sock,
|
||||||
...(options.reconnect
|
...(options.reconnect
|
||||||
? {
|
? {
|
||||||
|
|||||||
+12
-4
@@ -1,8 +1,10 @@
|
|||||||
import type { MessageState } from './defs/constants.ts';
|
import type { MessageState } from './defs/constants.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from './pdu.ts';
|
||||||
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
import { consts, constsById, messageTypeOf } from './defs/constants.ts';
|
import { consts, constsById, messageTypeOf } from './defs/constants.ts';
|
||||||
import { decodeMessage } from './message.ts';
|
import { decodeMessage } from './message.ts';
|
||||||
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramNumber, paramText } from './defs/types.ts';
|
import { paramNumber, paramText } from './defs/types.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,8 +161,14 @@ function receiptBody(pduObj: PduObject): string {
|
|||||||
).message;
|
).message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function receiptId(tlvId: ParamValue | undefined, receipt: Receipt | undefined): string | undefined {
|
function receiptId(
|
||||||
return nonEmptyText(tlvId) ?? nonEmptyText(receipt?.id);
|
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 {
|
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. */
|
/** 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);
|
const type = messageType(pduObj);
|
||||||
|
|
||||||
if (type === 'other') return undefined;
|
if (type === 'other') return undefined;
|
||||||
|
|
||||||
const body = receiptBody(pduObj);
|
const body = receiptBody(pduObj);
|
||||||
const receipt = body === '' ? undefined : parseReceipt(body);
|
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);
|
const { statusId, statusMsg } = receiptStatus(pduObj.tlvs.message_state?.tagValue, receipt);
|
||||||
|
|
||||||
if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined;
|
if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OnRequest } from './session-options.ts';
|
|||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from './pdu.ts';
|
||||||
import type { Session } from './session.ts';
|
import type { Session } from './session.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||||
import { bindCommands, defaults } from './session-options.ts';
|
import { bindCommands, defaults } from './session-options.ts';
|
||||||
import { concatInfo } from './udh.ts';
|
import { concatInfo } from './udh.ts';
|
||||||
@@ -18,6 +19,7 @@ export type IncomingRequestsOptions = {
|
|||||||
maxReassembly?: number | undefined;
|
maxReassembly?: number | undefined;
|
||||||
onRequest?: OnRequest | undefined;
|
onRequest?: OnRequest | undefined;
|
||||||
reassemblyTimeout?: number | undefined;
|
reassemblyTimeout?: number | undefined;
|
||||||
|
receiptIdFormat?: SmsIdFormat | undefined;
|
||||||
session: Session;
|
session: Session;
|
||||||
systemId?: string | undefined;
|
systemId?: string | undefined;
|
||||||
};
|
};
|
||||||
@@ -28,6 +30,7 @@ export class IncomingRequests {
|
|||||||
private readonly log: SmppLog;
|
private readonly log: SmppLog;
|
||||||
private readonly onRequest: OnRequest | undefined;
|
private readonly onRequest: OnRequest | undefined;
|
||||||
private readonly reassembler: Reassembler;
|
private readonly reassembler: Reassembler;
|
||||||
|
private readonly receiptIdFormat: SmsIdFormat | undefined;
|
||||||
private readonly session: Session;
|
private readonly session: Session;
|
||||||
private readonly systemId: string;
|
private readonly systemId: string;
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ export class IncomingRequests {
|
|||||||
maxOctets: options.maxOctets,
|
maxOctets: options.maxOctets,
|
||||||
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
||||||
});
|
});
|
||||||
|
this.receiptIdFormat = options.receiptIdFormat;
|
||||||
this.session = options.session;
|
this.session = options.session;
|
||||||
this.systemId = options.systemId ?? defaults.systemId;
|
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. */
|
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
|
||||||
private async onDeliverSm(pduObj: PduObject): Promise<void> {
|
private async onDeliverSm(pduObj: PduObject): Promise<void> {
|
||||||
const dlr = dlrFromPdu(pduObj);
|
const dlr = dlrFromPdu(pduObj, this.receiptIdFormat);
|
||||||
|
|
||||||
if (!dlr) {
|
if (!dlr) {
|
||||||
this.onMessage(pduObj);
|
this.onMessage(pduObj);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type { SendRespOptions, Sms, SmsInput } from './sms.ts';
|
|||||||
export type { ConcatInfo } from './udh.ts';
|
export type { ConcatInfo } from './udh.ts';
|
||||||
export type { Result, VoidResult } from './result.ts';
|
export type { Result, VoidResult } from './result.ts';
|
||||||
export type { SmppLog } from './log.ts';
|
export type { SmppLog } from './log.ts';
|
||||||
|
export type { SmsIdFormat, SmsIdFormats } from './sms-id.ts';
|
||||||
export type {
|
export type {
|
||||||
AuthenticateInput,
|
AuthenticateInput,
|
||||||
AuthenticateResult,
|
AuthenticateResult,
|
||||||
|
|||||||
+9
-3
@@ -3,8 +3,10 @@ import type { ParamValue } from './defs/types.ts';
|
|||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput } from './pdu.ts';
|
||||||
import type { Result } from './result.ts';
|
import type { Result } from './result.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
import { consts } from './defs/constants.ts';
|
import { consts } from './defs/constants.ts';
|
||||||
import { detect } from './defs/encodings.ts';
|
import { detect } from './defs/encodings.ts';
|
||||||
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[
|
|||||||
export type SendSmsDeps = {
|
export type SendSmsDeps = {
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
reference: number;
|
reference: number;
|
||||||
|
respIdFormat?: SmsIdFormat | undefined;
|
||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +101,10 @@ function checkSegments(allowed: number, segments: number): Error | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult {
|
function collectSent(
|
||||||
|
sent: Result<{ pduObj: PduObject }>[],
|
||||||
|
format: SmsIdFormat | undefined,
|
||||||
|
): SendSmsResult {
|
||||||
const pduObjs: PduObject[] = [];
|
const pduObjs: PduObject[] = [];
|
||||||
const smsIds: string[] = [];
|
const smsIds: string[] = [];
|
||||||
let failure: Error | undefined;
|
let failure: Error | undefined;
|
||||||
@@ -108,7 +114,7 @@ function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult {
|
|||||||
failure ??= one.err;
|
failure ??= one.err;
|
||||||
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
||||||
pduObjs.push(one.pduObj);
|
pduObjs.push(one.pduObj);
|
||||||
smsIds.push(paramText(one.pduObj.params.message_id));
|
smsIds.push(normaliseSmsId(paramText(one.pduObj.params.message_id), format));
|
||||||
} else {
|
} else {
|
||||||
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
|
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 }),
|
params: submitSmParams(sms, segment, { encoding, multipart }),
|
||||||
})));
|
})));
|
||||||
|
|
||||||
return collectSent(sent);
|
return collectSent(sent, deps.respIdFormat);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -4,8 +4,10 @@ import type { PduObject } from './pdu.ts';
|
|||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { Session } from './session.ts';
|
import type { Session } from './session.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { SmsIdFormats } from './sms-id.ts';
|
||||||
import type { Sms } from './sms.ts';
|
import type { Sms } from './sms.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
|
import { isSmsIdFormat } from './sms-id.ts';
|
||||||
|
|
||||||
export type SessionEvents = {
|
export type SessionEvents = {
|
||||||
close: [];
|
close: [];
|
||||||
@@ -83,6 +85,8 @@ export type SessionOptions = {
|
|||||||
responseTimeout?: number | undefined;
|
responseTimeout?: number | undefined;
|
||||||
/** How long a drain waits for the requests already on the wire. 0 waits forever. */
|
/** How long a drain waits for the requests already on the wire. 0 waits forever. */
|
||||||
shutdownTimeout?: number | undefined;
|
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;
|
sock: Socket;
|
||||||
/** This end's own identity, answered to the peer in place of the one it sent. */
|
/** This end's own identity, answered to the peer in place of the one it sent. */
|
||||||
systemId?: string | undefined;
|
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
|
* 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.
|
* 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][] = [
|
const limits: [string, number, number][] = [
|
||||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
['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 {};
|
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;
|
idleTimeout?: number | undefined;
|
||||||
maxOutstanding?: number | undefined;
|
maxOutstanding?: number | undefined;
|
||||||
maxReassembly?: number | undefined;
|
maxReassembly?: number | undefined;
|
||||||
reassemblyTimeout?: number | undefined;
|
reassemblyTimeout?: number | undefined;
|
||||||
responseTimeout?: number | undefined;
|
responseTimeout?: number | undefined;
|
||||||
shutdownTimeout?: number | undefined;
|
shutdownTimeout?: number | undefined;
|
||||||
|
smsIdFormat?: { receipt?: string | undefined; submitResp?: string | undefined } | undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
maxReassembly: options.maxReassembly,
|
maxReassembly: options.maxReassembly,
|
||||||
onRequest: options.onRequest,
|
onRequest: options.onRequest,
|
||||||
reassemblyTimeout: options.reassemblyTimeout,
|
reassemblyTimeout: options.reassemblyTimeout,
|
||||||
|
receiptIdFormat: options.smsIdFormat?.receipt,
|
||||||
session: this,
|
session: this,
|
||||||
systemId: options.systemId,
|
systemId: options.systemId,
|
||||||
});
|
});
|
||||||
@@ -209,6 +210,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
const sent = await submitSms({
|
const sent = await submitSms({
|
||||||
log: this.log,
|
log: this.log,
|
||||||
reference: this.nextConcatReference(),
|
reference: this.nextConcatReference(),
|
||||||
|
respIdFormat: this.options.smsIdFormat?.submitResp,
|
||||||
send: input => this.send(input, options),
|
send: input => this.send(input, options),
|
||||||
}, sms);
|
}, sms);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -206,6 +206,33 @@ describe('dlrFromPdu()', () => {
|
|||||||
assert.equal(dlr.receipt.sub, 1);
|
assert.equal(dlr.receipt.sub, 1);
|
||||||
assert.equal(dlr.receipt.text, 'hello');
|
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', () => {
|
describe('receiptCodes', () => {
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ describe('README: Client', () => {
|
|||||||
assert.equal((await reported).smsId, smsIds[0]);
|
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<Dlr>(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 => {
|
test('the documented sending options', async t => {
|
||||||
const smpp = await answeringServer(t);
|
const smpp = await answeringServer(t);
|
||||||
const incoming = once<Sms>(resolve => {
|
const incoming = once<Sms>(resolve => {
|
||||||
|
|||||||
+93
-28
@@ -15,11 +15,13 @@ import type { TestContext } from 'node:test';
|
|||||||
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
|
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
|
||||||
import { Session } from '../src/session.ts';
|
import { Session } from '../src/session.ts';
|
||||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||||
|
import { checkSessionOptions } from '../src/session-options.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client.ts';
|
||||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
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 { 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';
|
||||||
@@ -67,6 +69,34 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise(resolve => { setTimeout(resolve, ms); });
|
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<void> {
|
||||||
|
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<true> };
|
type Gate = { open: () => void; passed: Promise<true> };
|
||||||
|
|
||||||
/** A promise the test opens by hand, guarded by once() against waiting on one it never does. */
|
/** 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', () => {
|
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<void> {
|
|
||||||
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 => {
|
test('re-binds after the connection drops, keeping the same session object', async t => {
|
||||||
const smpp = await startServer(t);
|
const smpp = await startServer(t);
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
@@ -810,3 +812,66 @@ describe('graceful shutdown', () => {
|
|||||||
assert.deepEqual(reported, []);
|
assert.deepEqual(reported, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('message id notation', () => {
|
||||||
|
async function sendOne(session: Session, message: string): Promise<SendSmsResult> {
|
||||||
|
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<Dlr>(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<MessageDlr>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ rules there constrain every item below.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
The rewrite is **feature complete and green**: 248 tests, lint and typecheck clean, verified on Node
|
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18,
|
||||||
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
|
20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm node npm install
|
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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `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` |
|
||||||
@@ -155,13 +156,6 @@ session message is a change to every call site.
|
|||||||
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
|
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
|
||||||
publishes the numbers.
|
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,
|
- [ ] **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
|
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
|
facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the
|
||||||
|
|||||||
Reference in New Issue
Block a user