Let sendSms() name the esm_class messaging mode (#92)
* Regression tests for the messaging mode on sendSms() * Let sendSms() name the esm_class messaging mode * Document the messaging mode option and the decision behind it * Name the constants move for migrating consumers and share the refusal's value naming * Cover the messaging-mode refusals through submitSms() * Refuse transaction mode and a report under datagram mode on sendSms() * Narrow the mode checker and name the default mode once
This commit is contained in:
+26
-3
@@ -43,13 +43,10 @@ export const consts = {
|
||||
},
|
||||
ESM_CLASS: {
|
||||
CONVERSATION_ABORT: 0x18,
|
||||
DATAGRAM: 0x01,
|
||||
DELIVERY_ACKNOWLEDGEMENT: 0x08,
|
||||
FORWARD: 0x02,
|
||||
INTERMEDIATE_DELIVERY: 0x20,
|
||||
MC_DELIVERY_RECEIPT: 0x04,
|
||||
SET_REPLY_PATH: 0x80,
|
||||
STORE_FORWARD: 0x03,
|
||||
UDH_INDICATOR: 0x40,
|
||||
USER_ACKNOWLEDGEMENT: 0x10,
|
||||
},
|
||||
@@ -65,6 +62,13 @@ export const consts = {
|
||||
UNDELIVERABLE: 5,
|
||||
UNKNOWN: 7,
|
||||
},
|
||||
/** SMPP 3.4 5.2.12 bits 1-0 of esm_class, the field the rest of that octet is OR-ed into. */
|
||||
MESSAGING_MODE: {
|
||||
DATAGRAM: 0x01,
|
||||
FORWARD: 0x02,
|
||||
SMSC_DEFAULT: 0x00,
|
||||
STORE_FORWARD: 0x03,
|
||||
},
|
||||
NETWORK: {
|
||||
CDMA: 0x03,
|
||||
GENERIC: 0x00,
|
||||
@@ -114,6 +118,25 @@ export function messageTypeOf(esmClass: number): number {
|
||||
|
||||
export type ConstGroup = keyof typeof consts;
|
||||
export type MessageState = keyof typeof consts.MESSAGE_STATE;
|
||||
export type MessagingMode = keyof typeof consts.MESSAGING_MODE;
|
||||
|
||||
/** SMPP 3.4 2.10.3 carries transaction mode on `data_sm` alone, so a `submit_sm` never asks for it. */
|
||||
const transactionMode = 'FORWARD' satisfies MessagingMode;
|
||||
|
||||
export const defaultMessagingMode = 'SMSC_DEFAULT' satisfies MessagingMode;
|
||||
|
||||
export type SubmitMessagingMode = Exclude<MessagingMode, typeof transactionMode>;
|
||||
|
||||
export const submitMessagingModes: readonly string[] = Object.keys(consts.MESSAGING_MODE)
|
||||
.filter(mode => mode !== transactionMode);
|
||||
|
||||
export function isMessagingMode(value: unknown): value is MessagingMode {
|
||||
return typeof value === 'string' && Object.hasOwn(consts.MESSAGING_MODE, value);
|
||||
}
|
||||
|
||||
export function isSubmitMessagingMode(value: unknown): value is SubmitMessagingMode {
|
||||
return isMessagingMode(value) && value !== transactionMode;
|
||||
}
|
||||
|
||||
// Aliased values (NPI.IP === NPI.INTERNET === 0x0E) resolve to whichever name sorts last.
|
||||
export const constsById: Record<string, Record<number, string>> = {};
|
||||
|
||||
@@ -8,3 +8,8 @@ export function errorFrom(reason: unknown): Error {
|
||||
return new Error('A thrown value that cannot be converted to a string');
|
||||
}
|
||||
}
|
||||
|
||||
/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */
|
||||
export function namedValue(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value;
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export type {
|
||||
SessionOptions,
|
||||
} from './session.ts';
|
||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
||||
export type { ConstGroup, MessageState } from './defs/constants.ts';
|
||||
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
||||
export type { Encoding, EncodingName } from './defs/encodings.ts';
|
||||
export type { ErrorName } from './defs/errors.ts';
|
||||
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
|
||||
+54
-5
@@ -1,12 +1,14 @@
|
||||
import type { EncodingName } from './defs/encodings.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { SubmitMessagingMode } from './defs/constants.ts';
|
||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
||||
import type { Result } from './result.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import type { SmsIdNotation } from './sms-id.ts';
|
||||
import { UnansweredError } from './unanswered-error.ts';
|
||||
import { consts } from './defs/constants.ts';
|
||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
||||
import { detect } from './defs/encodings.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||
@@ -21,6 +23,8 @@ export type SendSmsOptions = {
|
||||
/** Refuse before sending anything if the message needs more than this many segments. */
|
||||
maxSegments?: number;
|
||||
message: string;
|
||||
/** The esm_class messaging mode, SMPP 3.4 5.2.12. Absent leaves the choice to the SMSC. */
|
||||
messagingMode?: SubmitMessagingMode;
|
||||
scheduleDeliveryTime?: Date | number | string;
|
||||
sourceAddrNpi?: number;
|
||||
sourceAddrTon?: number;
|
||||
@@ -28,6 +32,9 @@ export type SendSmsOptions = {
|
||||
validityPeriod?: Date | number | string;
|
||||
};
|
||||
|
||||
/** The options as they arrive: a caller without types can put anything in the checked field. */
|
||||
export type SendSmsInput = Omit<SendSmsOptions, 'messagingMode'> & { messagingMode?: unknown };
|
||||
|
||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||
export type SendSmsResult = {
|
||||
err?: Error;
|
||||
@@ -52,6 +59,7 @@ export type SendSmsDeps = {
|
||||
|
||||
type SegmentOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode?: SubmitMessagingMode;
|
||||
multipart: boolean;
|
||||
};
|
||||
|
||||
@@ -67,8 +75,15 @@ function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
||||
return encoding === 'UCS2' ? 0x18 : 0x10;
|
||||
}
|
||||
|
||||
/** The mode the caller named sits beside the UDH indicator, which a segment carrying one must keep. */
|
||||
function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean): number {
|
||||
const udh = multipart ? consts.ESM_CLASS.UDH_INDICATOR : 0;
|
||||
|
||||
return consts.MESSAGING_MODE[mode ?? defaultMessagingMode] | udh;
|
||||
}
|
||||
|
||||
export function submitSmParams(
|
||||
sms: SendSmsOptions,
|
||||
sms: SendSmsInput,
|
||||
segment: Buffer,
|
||||
options: SegmentOptions,
|
||||
): Record<string, ParamValue> {
|
||||
@@ -77,13 +92,13 @@ export function submitSmParams(
|
||||
destination_addr: sms.to,
|
||||
dest_addr_npi: sms.destinationAddrNpi ?? 0,
|
||||
dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to),
|
||||
esm_class: esmClassFor(options.messagingMode, options.multipart),
|
||||
short_message: segment,
|
||||
source_addr: sms.from,
|
||||
source_addr_npi: sms.sourceAddrNpi ?? 0,
|
||||
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
|
||||
};
|
||||
|
||||
if (options.multipart) params.esm_class = consts.ESM_CLASS.UDH_INDICATOR;
|
||||
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
|
||||
if (sms.scheduleDeliveryTime !== undefined) {
|
||||
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
|
||||
@@ -95,6 +110,36 @@ export function submitSmParams(
|
||||
return params;
|
||||
}
|
||||
|
||||
function refusedMode(mode: unknown): Error {
|
||||
if (isMessagingMode(mode)) {
|
||||
return new Error(`messagingMode ${mode} is data_sm only (SMPP 3.4 2.10.3), name ${submitMessagingModes.join(', ')}`);
|
||||
}
|
||||
|
||||
return new Error(`messagingMode must be ${submitMessagingModes.join(', ')}, got ${namedValue(mode)}`);
|
||||
}
|
||||
|
||||
/** SMPP 3.4 2.10.2 defines the delivery report away under datagram mode, so one asked for never comes. */
|
||||
function checkedMode(
|
||||
messagingMode: SubmitMessagingMode,
|
||||
dlr: boolean,
|
||||
): Result<{ messagingMode: SubmitMessagingMode }> {
|
||||
if (dlr && messagingMode === 'DATAGRAM') {
|
||||
return { err: new Error('messagingMode DATAGRAM has no delivery report to ask for, so dlr must be false') };
|
||||
}
|
||||
|
||||
return { messagingMode };
|
||||
}
|
||||
|
||||
function checkMessagingMode(
|
||||
mode: unknown,
|
||||
dlr: boolean,
|
||||
): Result<{ messagingMode: SubmitMessagingMode }> {
|
||||
if (mode === undefined) return checkedMode(defaultMessagingMode, dlr);
|
||||
if (isSubmitMessagingMode(mode)) return checkedMode(mode, dlr);
|
||||
|
||||
return { err: refusedMode(mode) };
|
||||
}
|
||||
|
||||
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
||||
function checkSegments(allowed: number, segments: number): Error | undefined {
|
||||
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
|
||||
@@ -140,7 +185,11 @@ function collectSent(
|
||||
}
|
||||
|
||||
/** Puts a message on the wire as one submit_sm per segment. */
|
||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
|
||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
|
||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||
|
||||
if (mode.err) return unsent(mode.err);
|
||||
|
||||
const allowed = sms.maxSegments ?? maxSegments;
|
||||
const encoding = sms.encoding ?? detect(sms.message);
|
||||
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
||||
@@ -156,7 +205,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
|
||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: submitSmParams(sms, segment, { encoding, multipart }),
|
||||
params: submitSmParams(sms, segment, { encoding, messagingMode: mode.messagingMode, multipart }),
|
||||
})));
|
||||
|
||||
return collectSent(sent, deps.respIdNotation);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Sms } from './sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { backoffDefaults } from './reconnect-loop.ts';
|
||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
|
||||
export type SessionEvents = {
|
||||
close: [];
|
||||
@@ -227,8 +228,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
|
||||
|
||||
if (notation === undefined || isSmsIdNotation(notation)) continue;
|
||||
|
||||
// String() throws on a null-prototype object, and this value is whatever the caller passed.
|
||||
const got = typeof notation === 'string' ? notation : typeof notation;
|
||||
const got = namedValue(notation);
|
||||
|
||||
return { err: new Error(`smsIdFormat.${place} must be ${smsIdNotations.join(' or ')}, got ${got}`) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user