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:
2026-09-09 16:40:32 +02:00
committed by GitHub
parent bd466d393a
commit e80b07167c
11 changed files with 422 additions and 31 deletions
+28 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,