Refuse what we cannot send: an alphabet that cannot carry the message, and a time nobody can read (#97)
* Regression tests for a named alphabet and a time that cannot carry the send * Refuse a message the named alphabet cannot carry, and a time nobody can read * Close the architecture review: a builder that cannot drop a time, and docs that hold * Refuse a time of no known kind, and clamp a relative period to what the format holds * Reflow the decision record and name the clamp in its test
This commit is contained in:
@@ -121,6 +121,22 @@ export function detect(value: string): EncodingName {
|
||||
return 'UCS2';
|
||||
}
|
||||
|
||||
export type Unencodable = { char: string; index: number };
|
||||
|
||||
/** The first character `encoding` cannot carry, or undefined where it carries every one of them. */
|
||||
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
|
||||
const codec = encodings[encoding];
|
||||
let index = 0;
|
||||
|
||||
for (const char of message) {
|
||||
if (codec.decode(codec.encode(char)) !== char) return { char, index };
|
||||
|
||||
index += char.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
|
||||
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
|
||||
|
||||
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
||||
export { consts, constsById } from './defs/constants.ts';
|
||||
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf } from './defs/encodings.ts';
|
||||
export { detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||
export { types } from './defs/types.ts';
|
||||
@@ -62,7 +62,7 @@ export type {
|
||||
} from './session.ts';
|
||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
||||
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
||||
export type { Encoding, EncodingName } from './defs/encodings.ts';
|
||||
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
|
||||
export type { ErrorName } from './defs/errors.ts';
|
||||
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
export type { PduHeader } from './pdu-refusal.ts';
|
||||
|
||||
+29
-21
@@ -119,33 +119,41 @@ const relativeTime = /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)000R$/;
|
||||
/** The SMPP absolute and relative time format, as used by validity_period and friends. */
|
||||
export const smppTime = {
|
||||
/**
|
||||
* A Date becomes an absolute UTC time; a number is a relative period in seconds. Relative
|
||||
* periods are expressed in days and below, so anything past 99 days is clamped to that.
|
||||
* A Date becomes an absolute UTC time; a number is a relative period in seconds, expressed in
|
||||
* days and below, so anything past the 99d 23:59:59 the format holds is clamped to it; a string
|
||||
* is a stamp the caller formatted itself and is passed through. A value naming no instant or
|
||||
* period is refused.
|
||||
*/
|
||||
encode(value: Date | number | string): string {
|
||||
if (typeof value === 'string') return value;
|
||||
encode(value: Date | number | string): Result<{ text: string }> {
|
||||
if (typeof value === 'string') return { text: value };
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const total = Math.max(0, Math.floor(value));
|
||||
const days = Math.min(99, Math.floor(total / 86400));
|
||||
const capped = days === 99 ? 99 * 86400 + 86399 : total;
|
||||
if (!Number.isFinite(value)) return { err: new Error(`Not an SMPP time: ${String(value)}`) };
|
||||
|
||||
return '0000'
|
||||
+ pad(Math.floor(capped / 86400), 2)
|
||||
+ pad(Math.floor(capped / 3600) % 24, 2)
|
||||
+ pad(Math.floor(capped / 60) % 60, 2)
|
||||
+ pad(capped % 60, 2)
|
||||
+ '000R';
|
||||
const capped = Math.min(Math.max(0, Math.floor(value)), 99 * 86400 + 86399);
|
||||
|
||||
return {
|
||||
text: '0000'
|
||||
+ pad(Math.floor(capped / 86400), 2)
|
||||
+ pad(Math.floor(capped / 3600) % 24, 2)
|
||||
+ pad(Math.floor(capped / 60) % 60, 2)
|
||||
+ pad(capped % 60, 2)
|
||||
+ '000R',
|
||||
};
|
||||
}
|
||||
|
||||
return pad(value.getUTCFullYear() % 100, 2)
|
||||
+ pad(value.getUTCMonth() + 1, 2)
|
||||
+ pad(value.getUTCDate(), 2)
|
||||
+ pad(value.getUTCHours(), 2)
|
||||
+ pad(value.getUTCMinutes(), 2)
|
||||
+ pad(value.getUTCSeconds(), 2)
|
||||
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
|
||||
+ '00+';
|
||||
if (Number.isNaN(value.getTime())) return { err: new Error('Not an SMPP time: an invalid Date') };
|
||||
|
||||
return {
|
||||
text: pad(value.getUTCFullYear() % 100, 2)
|
||||
+ pad(value.getUTCMonth() + 1, 2)
|
||||
+ pad(value.getUTCDate(), 2)
|
||||
+ pad(value.getUTCHours(), 2)
|
||||
+ pad(value.getUTCMinutes(), 2)
|
||||
+ pad(value.getUTCSeconds(), 2)
|
||||
+ pad(Math.floor(value.getUTCMilliseconds() / 100), 1)
|
||||
+ '00+',
|
||||
};
|
||||
},
|
||||
|
||||
decode(value: string): Result<{ date: Date }> {
|
||||
|
||||
+67
-17
@@ -1,4 +1,4 @@
|
||||
import type { EncodingName } from './defs/encodings.ts';
|
||||
import type { EncodingName, Unencodable } 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';
|
||||
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
||||
import type { SmsIdNotation } from './sms-id.ts';
|
||||
import { UnansweredError } from './unanswered-error.ts';
|
||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
||||
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
|
||||
import { detect, encodingNames, isEncodingName, unencodable } from './defs/encodings.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
@@ -33,10 +33,13 @@ export type SendSmsOptions = {
|
||||
};
|
||||
|
||||
/** The options as they arrive: a caller without types can put anything in the checked fields. */
|
||||
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
|
||||
encoding?: unknown;
|
||||
messagingMode?: unknown;
|
||||
};
|
||||
export type SendSmsInput =
|
||||
Omit<SendSmsOptions, 'encoding' | 'messagingMode' | 'scheduleDeliveryTime' | 'validityPeriod'> & {
|
||||
encoding?: unknown;
|
||||
messagingMode?: unknown;
|
||||
scheduleDeliveryTime?: unknown;
|
||||
validityPeriod?: unknown;
|
||||
};
|
||||
|
||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||
export type SendSmsResult = {
|
||||
@@ -60,16 +63,24 @@ export type SendSmsDeps = {
|
||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||
};
|
||||
|
||||
/** The time fields as the wire spells them, settled once for a message rather than per segment. */
|
||||
type SendTimes = {
|
||||
scheduleDeliveryTime?: string;
|
||||
validityPeriod?: string;
|
||||
};
|
||||
|
||||
/** What the checks below settle, before a segment exists to carry it. */
|
||||
type CheckedOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode: SubmitMessagingMode;
|
||||
times: SendTimes;
|
||||
};
|
||||
|
||||
type SegmentOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode?: SubmitMessagingMode;
|
||||
multipart: boolean;
|
||||
times?: SendTimes;
|
||||
};
|
||||
|
||||
/** Alphanumeric senders must be TON 5. */
|
||||
@@ -92,7 +103,7 @@ function esmClassFor(mode: SubmitMessagingMode | undefined, multipart: boolean):
|
||||
}
|
||||
|
||||
export function submitSmParams(
|
||||
sms: SendSmsInput,
|
||||
sms: Omit<SendSmsInput, 'scheduleDeliveryTime' | 'validityPeriod'>,
|
||||
segment: Buffer,
|
||||
options: SegmentOptions,
|
||||
): Record<string, ParamValue> {
|
||||
@@ -108,13 +119,12 @@ export function submitSmParams(
|
||||
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
|
||||
};
|
||||
|
||||
const schedule = options.times?.scheduleDeliveryTime;
|
||||
const validity = options.times?.validityPeriod;
|
||||
|
||||
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
|
||||
if (sms.scheduleDeliveryTime !== undefined) {
|
||||
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
|
||||
}
|
||||
if (sms.validityPeriod !== undefined) {
|
||||
params.validity_period = smppTime.encode(sms.validityPeriod);
|
||||
}
|
||||
if (schedule !== undefined) params.schedule_delivery_time = schedule;
|
||||
if (validity !== undefined) params.validity_period = validity;
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -157,11 +167,42 @@ function refusedEncoding(encoding: unknown): Error {
|
||||
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
|
||||
}
|
||||
|
||||
function refusedText(encoding: EncodingName, at: Unencodable): Error {
|
||||
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
|
||||
|
||||
return new Error(`encoding ${encoding} cannot carry ${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}; name UCS2 or leave encoding out`);
|
||||
}
|
||||
|
||||
/** An alphabet the caller named has to carry the message; the one detect() picks always does. */
|
||||
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
|
||||
if (encoding === undefined) return { encoding: detect(message) };
|
||||
if (isEncodingName(encoding)) return { encoding };
|
||||
if (!isEncodingName(encoding)) return { err: refusedEncoding(encoding) };
|
||||
|
||||
return { err: refusedEncoding(encoding) };
|
||||
const lost = unencodable(message, encoding);
|
||||
|
||||
return lost ? { err: refusedText(encoding, lost) } : { encoding };
|
||||
}
|
||||
|
||||
function checkTimes(sms: SendSmsInput): Result<{ times: SendTimes }> {
|
||||
const times: SendTimes = {};
|
||||
|
||||
for (const option of ['scheduleDeliveryTime', 'validityPeriod'] as const) {
|
||||
const value = sms[option];
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (typeof value !== 'number' && typeof value !== 'string' && !(value instanceof Date)) {
|
||||
return { err: new Error(`${option} must be a Date, a number of seconds or an SMPP stamp, got ${namedValue(value)}`) };
|
||||
}
|
||||
|
||||
const encoded = smppTime.encode(value);
|
||||
|
||||
if (encoded.err) return { err: new Error(`${option}: ${encoded.err.message}`) };
|
||||
|
||||
times[option] = encoded.text;
|
||||
}
|
||||
|
||||
return { times };
|
||||
}
|
||||
|
||||
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
|
||||
@@ -185,7 +226,11 @@ function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||
|
||||
if (unspellable) return { err: unspellable };
|
||||
|
||||
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
|
||||
const times = checkTimes(sms);
|
||||
|
||||
if (times.err) return { err: times.err };
|
||||
|
||||
return { encoding: chosen.encoding, messagingMode: mode.messagingMode, times: times.times };
|
||||
}
|
||||
|
||||
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
||||
@@ -252,7 +297,12 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
|
||||
// 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, messagingMode: options.messagingMode, multipart }),
|
||||
params: submitSmParams(sms, segment, {
|
||||
encoding,
|
||||
messagingMode: options.messagingMode,
|
||||
multipart,
|
||||
times: options.times,
|
||||
}),
|
||||
})));
|
||||
|
||||
return collectSent(sent, deps.respIdNotation);
|
||||
|
||||
Reference in New Issue
Block a user