Read the GSM 03.38 message class where the spec puts it (#95)
* Regression tests for the GSM 03.38 message class behind sms.flash * Read the GSM 03.38 message class where the spec puts it, and refuse a flash message no alphabet can carry * Keep the code span in the message-class decision on one line * Stop offering the flash message class as an alphabet, and check the encoding option by name * Settle every refusable send option in one check, and record what the review left open * Say what the flash refusal and the encoding option actually do * Keep the README off the alphabet whose long messages do not fit
This commit is contained in:
+28
-11
@@ -1,4 +1,4 @@
|
||||
export type EncodingName = 'ASCII' | 'FLASH' | 'LATIN1' | 'UCS2';
|
||||
export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2';
|
||||
|
||||
export type Encoding = {
|
||||
decode: (buffer: Uint8Array) => string;
|
||||
@@ -104,11 +104,16 @@ const ucs2: Encoding = {
|
||||
|
||||
export const encodings: Record<EncodingName, Encoding> = {
|
||||
ASCII: ascii,
|
||||
FLASH: ascii,
|
||||
LATIN1: latin1,
|
||||
UCS2: ucs2,
|
||||
};
|
||||
|
||||
export const encodingNames: readonly string[] = Object.keys(encodings);
|
||||
|
||||
export function isEncodingName(value: unknown): value is EncodingName {
|
||||
return typeof value === 'string' && Object.hasOwn(encodings, value);
|
||||
}
|
||||
|
||||
export function detect(value: string): EncodingName {
|
||||
if (encodings.ASCII.match(value)) return 'ASCII';
|
||||
if (encodings.LATIN1.match(value)) return 'LATIN1';
|
||||
@@ -116,21 +121,33 @@ export function detect(value: string): EncodingName {
|
||||
return 'UCS2';
|
||||
}
|
||||
|
||||
/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
|
||||
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
||||
if ((dataCoding & 0xF0) === 0x10) {
|
||||
const alphabet = (dataCoding >> 2) & 0x03;
|
||||
|
||||
if (alphabet === 0x01) return 'LATIN1';
|
||||
|
||||
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function messageClassOf(dataCoding: number): number | undefined {
|
||||
if ((dataCoding & 0x80) === 0) {
|
||||
return (dataCoding & 0x10) === 0x10 ? dataCoding & 0x03 : undefined;
|
||||
}
|
||||
|
||||
return (dataCoding & 0xF0) === 0xF0 ? dataCoding & 0x03 : undefined;
|
||||
}
|
||||
|
||||
// A class is the only evidence a peer below 0x80 is spelling 03.38 rather than SMPP's flat table,
|
||||
// which contradicts it and wins: 0x03 is Latin-1 here, GSM 7-bit there.
|
||||
/** A class group puts the alphabet in bits 3-2, or in bit 2 alone above 0xF0. */
|
||||
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
||||
if (messageClassOf(dataCoding) === undefined) return undefined;
|
||||
|
||||
if ((dataCoding & 0xF0) === 0xF0) {
|
||||
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const alphabet = (dataCoding >> 2) & 0x03;
|
||||
|
||||
if (alphabet === 0x01) return 'LATIN1';
|
||||
|
||||
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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 } from './defs/encodings.ts';
|
||||
export { detect, encodingByDataCoding, encodings, messageClassOf } 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';
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export function bitCount(message: string, encoding?: EncodingName): number {
|
||||
const encoded = encodings[resolved].encode(message);
|
||||
|
||||
// GSM characters are packed seven bits to a septet; everything else stays octet-aligned.
|
||||
return resolved === 'ASCII' || resolved === 'FLASH' ? encoded.length * 7 : encoded.length * 8;
|
||||
return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+56
-9
@@ -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 } from './defs/encodings.ts';
|
||||
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
|
||||
import { namedValue } from './error-from.ts';
|
||||
import { normaliseSmsId } from './sms-id.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
@@ -32,8 +32,11 @@ 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 };
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||
export type SendSmsResult = {
|
||||
@@ -57,6 +60,12 @@ export type SendSmsDeps = {
|
||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||
};
|
||||
|
||||
/** What the checks below settle, before a segment exists to carry it. */
|
||||
type CheckedOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode: SubmitMessagingMode;
|
||||
};
|
||||
|
||||
type SegmentOptions = {
|
||||
encoding: EncodingName;
|
||||
messagingMode?: SubmitMessagingMode;
|
||||
@@ -140,6 +149,45 @@ function checkMessagingMode(
|
||||
return { err: refusedMode(mode) };
|
||||
}
|
||||
|
||||
function refusedEncoding(encoding: unknown): Error {
|
||||
if (encoding === 'FLASH') {
|
||||
return new Error('encoding FLASH is a message class rather than an alphabet; ask for it as flash: true beside the alphabet you want');
|
||||
}
|
||||
|
||||
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
|
||||
}
|
||||
|
||||
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
|
||||
if (encoding === undefined) return { encoding: detect(message) };
|
||||
if (isEncodingName(encoding)) return { encoding };
|
||||
|
||||
return { err: refusedEncoding(encoding) };
|
||||
}
|
||||
|
||||
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
|
||||
function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
|
||||
if (!flash || encoding !== 'LATIN1') return undefined;
|
||||
|
||||
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
|
||||
}
|
||||
|
||||
/** Every option a send can be refused for, so nothing is built for a message that will not go. */
|
||||
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||
|
||||
if (mode.err) return { err: mode.err };
|
||||
|
||||
const chosen = checkEncoding(sms.encoding, sms.message);
|
||||
|
||||
if (chosen.err) return { err: chosen.err };
|
||||
|
||||
const unspellable = checkFlash(chosen.encoding, sms.flash === true);
|
||||
|
||||
if (unspellable) return { err: unspellable };
|
||||
|
||||
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -186,14 +234,13 @@ function collectSent(
|
||||
|
||||
/** Puts a message on the wire as one submit_sm per segment. */
|
||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
|
||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||
const options = checkOptions(sms);
|
||||
|
||||
if (mode.err) return unsent(mode.err);
|
||||
if (options.err) return unsent(options.err);
|
||||
|
||||
const allowed = sms.maxSegments ?? maxSegments;
|
||||
const encoding = sms.encoding ?? detect(sms.message);
|
||||
const encoding = options.encoding;
|
||||
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
||||
const refused = checkSegments(allowed, segments.length);
|
||||
const refused = checkSegments(sms.maxSegments ?? maxSegments, segments.length);
|
||||
|
||||
if (refused) return unsent(refused);
|
||||
|
||||
@@ -205,7 +252,7 @@ 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: mode.messagingMode, multipart }),
|
||||
params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }),
|
||||
})));
|
||||
|
||||
return collectSent(sent, deps.respIdNotation);
|
||||
|
||||
+6
-1
@@ -5,6 +5,7 @@ import type { Result, VoidResult } from './result.ts';
|
||||
import type { Session } from './session.ts';
|
||||
import { UnansweredError } from './unanswered-error.ts';
|
||||
import { consts } from './defs/constants.ts';
|
||||
import { messageClassOf } from './defs/encodings.ts';
|
||||
import { receiptCodes, transientStates } from './dlr.ts';
|
||||
import { smppDate } from './message.ts';
|
||||
import { respIdParams, segmentId } from './sms-id.ts';
|
||||
@@ -35,6 +36,7 @@ export type Sms = {
|
||||
*/
|
||||
answeredOnArrival: boolean;
|
||||
dlr: boolean;
|
||||
/** GSM 03.38 message class 0: shown on arrival and not stored. */
|
||||
flash: boolean;
|
||||
from: string;
|
||||
message: string;
|
||||
@@ -71,6 +73,9 @@ export type SmsHandlers = {
|
||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||
};
|
||||
|
||||
/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */
|
||||
const immediateDisplayClass = 0;
|
||||
|
||||
export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||||
const first = input.pduObjs[0];
|
||||
const registered = first?.params.registered_delivery;
|
||||
@@ -80,7 +85,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||||
const sms: Sms = {
|
||||
answeredOnArrival: input.answeredAs !== undefined,
|
||||
dlr: typeof registered === 'number' && registered !== 0,
|
||||
flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10,
|
||||
flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass,
|
||||
from: input.from,
|
||||
message: input.message,
|
||||
pduObjs: input.pduObjs,
|
||||
|
||||
Reference in New Issue
Block a user