5a0cf56754
* Fixtures for the receipt bodies operator documentation publishes * Read the stat:FAILED several operators write as UNDELIVERABLE * Cover the 16-bit UDH concatenation element with a fixture * Record phase 9 of the interoperability plan * Share the dummy SMSC and tighten the operator fixtures * Correct what the test conventions claim about the doubles and the tree * Read a receipt date that carries its century * Read the stat spelling CM.com publishes * Say what the receipt status table actually holds
240 lines
7.9 KiB
TypeScript
240 lines
7.9 KiB
TypeScript
import type { MessageState } from './defs/constants.ts';
|
|
import type { ParamValue } from './defs/types.ts';
|
|
import type { PduObject } from './pdu.ts';
|
|
import type { SmsIdFormat } from './sms-id.ts';
|
|
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
|
import { encodings } from './defs/encodings.ts';
|
|
import { messageOctets } from './message-body.ts';
|
|
import { normaliseSmsId } from './sms-id.ts';
|
|
import { paramNumber, paramText } from './defs/types.ts';
|
|
import { udhLength } from './udh.ts';
|
|
|
|
/**
|
|
* The status codes carried in a receipt's `stat:` field, mapped to the message_state values they
|
|
* correspond to. Two of them are not the seven characters the field is meant to hold.
|
|
*/
|
|
const receiptStates: Record<string, MessageState> = {
|
|
ACCEPTD: 'ACCEPTED',
|
|
DELETED: 'DELETED',
|
|
// CM.com publishes this eight-character spelling of DELIVRD in its own code table.
|
|
DELIVERD: 'DELIVERED',
|
|
DELIVRD: 'DELIVERED',
|
|
ENROUTE: 'ENROUTE',
|
|
EXPIRED: 'EXPIRED',
|
|
// Kaleyra and Route Mobile document it as a terminal failure; Appendix B does not define it.
|
|
FAILED: 'UNDELIVERABLE',
|
|
REJECTD: 'REJECTED',
|
|
UNDELIV: 'UNDELIVERABLE',
|
|
UNKNOWN: 'UNKNOWN',
|
|
};
|
|
|
|
/** message_state values as the seven-character codes a receipt's `stat:` field must carry. */
|
|
export const receiptCodes: Record<MessageState, string> = {
|
|
ACCEPTED: 'ACCEPTD',
|
|
DELETED: 'DELETED',
|
|
DELIVERED: 'DELIVRD',
|
|
ENROUTE: 'ENROUTE',
|
|
EXPIRED: 'EXPIRED',
|
|
REJECTED: 'REJECTD',
|
|
SCHEDULED: 'ENROUTE',
|
|
SKIPPED: 'UNKNOWN',
|
|
UNDELIVERABLE: 'UNDELIV',
|
|
UNKNOWN: 'UNKNOWN',
|
|
};
|
|
|
|
export type Receipt = {
|
|
doneDate: string | undefined;
|
|
dlvrd: number | undefined;
|
|
err: string | undefined;
|
|
id: string | undefined;
|
|
stat: string | undefined;
|
|
sub: number | undefined;
|
|
submitDate: string | undefined;
|
|
text: string | undefined;
|
|
};
|
|
|
|
export type Dlr = {
|
|
doneDate: Date | undefined;
|
|
errorCode: string | undefined;
|
|
intermediate: boolean;
|
|
receipt: Receipt | undefined;
|
|
smsId: string | undefined;
|
|
statusId: number;
|
|
statusMsg: MessageState;
|
|
};
|
|
|
|
const field = (name: string) => new RegExp(`\\b${name}:(\\S*)`, 'i');
|
|
|
|
const patterns = {
|
|
dlvrd: field('dlvrd'),
|
|
doneDate: field('done date'),
|
|
err: field('err'),
|
|
id: field('id'),
|
|
stat: field('stat'),
|
|
sub: field('sub'),
|
|
submitDate: field('submit date'),
|
|
// The one field that may hold a space, carrying the message's own start, so it ends at its line.
|
|
text: /\btext:([^\r\n]*)/i,
|
|
};
|
|
|
|
function toNumber(value: string | undefined): number | undefined {
|
|
// Number('') is 0, which would report a receipt that stated no count as one that stated none sent.
|
|
if (value === undefined || value === '') return undefined;
|
|
|
|
const parsed = Number(value);
|
|
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
/** Delivery receipt dates are YYMMDDhhmm, sometimes with seconds, and CM.com states the century. */
|
|
function receiptDate(value: string | undefined): Date | undefined {
|
|
if (value === undefined) return undefined;
|
|
|
|
// Ten, twelve and fourteen digits, so no width can be read as another.
|
|
const match = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)$/.exec(value)
|
|
?? /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)?$/.exec(value);
|
|
|
|
if (!match) return undefined;
|
|
|
|
const [, years, months, days, hours, minutes, seconds] = match;
|
|
const century = years?.length === 4 ? 0 : Math.floor(new Date().getUTCFullYear() / 100) * 100;
|
|
const year = century + Number(years);
|
|
const date = new Date(Date.UTC(
|
|
year,
|
|
Number(months) - 1,
|
|
Number(days),
|
|
Number(hours),
|
|
Number(minutes),
|
|
Number(seconds ?? 0),
|
|
));
|
|
|
|
// Date.UTC rolls 31 February into March rather than refusing it, and reads year 26 as 1926.
|
|
const rolled = date.getUTCFullYear() !== year
|
|
|| date.getUTCMonth() !== Number(months) - 1
|
|
|| date.getUTCDate() !== Number(days)
|
|
|| date.getUTCHours() !== Number(hours)
|
|
|| date.getUTCMinutes() !== Number(minutes)
|
|
|| date.getUTCSeconds() !== Number(seconds ?? 0);
|
|
|
|
return rolled ? undefined : date;
|
|
}
|
|
|
|
/**
|
|
* Parses the standard receipt body, as in
|
|
* `id:0123 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:…`
|
|
*/
|
|
export function parseReceipt(message: string): Receipt {
|
|
const read = (pattern: RegExp): string | undefined => pattern.exec(message)?.[1];
|
|
|
|
return {
|
|
dlvrd: toNumber(read(patterns.dlvrd)),
|
|
doneDate: read(patterns.doneDate),
|
|
err: read(patterns.err),
|
|
id: read(patterns.id),
|
|
stat: read(patterns.stat),
|
|
sub: toNumber(read(patterns.sub)),
|
|
submitDate: read(patterns.submitDate),
|
|
text: read(patterns.text),
|
|
};
|
|
}
|
|
|
|
type MessageType = 'intermediate' | 'other' | 'receipt' | 'unmarked';
|
|
|
|
/** SMPP 3.4 Appendix B lists every other receipt state as final. */
|
|
export const transientStates: readonly MessageState[] = ['ENROUTE', 'SCHEDULED'];
|
|
|
|
/** Written by the far-end SME, not by the MC reporting on a message we submitted. */
|
|
const smeMessageTypes: readonly number[] = [
|
|
consts.ESM_CLASS.CONVERSATION_ABORT,
|
|
consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT,
|
|
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
|
|
];
|
|
|
|
function nonEmptyText(value: ParamValue | undefined): string | undefined {
|
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
}
|
|
|
|
function messageType(pduObj: PduObject): MessageType {
|
|
const type = messageTypeOf(paramNumber(pduObj.params.esm_class, 0));
|
|
|
|
if (type === consts.ESM_CLASS.MC_DELIVERY_RECEIPT) return 'receipt';
|
|
if (type === consts.ESM_CLASS.INTERMEDIATE_DELIVERY) return 'intermediate';
|
|
if (smeMessageTypes.includes(type)) return 'other';
|
|
|
|
return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt';
|
|
}
|
|
|
|
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
|
|
function receiptBody(pduObj: PduObject): string {
|
|
const octets = messageOctets(pduObj);
|
|
|
|
if (octets === undefined) return paramText(pduObj.params.short_message);
|
|
|
|
const body = hasUdh(paramNumber(pduObj.params.esm_class, 0))
|
|
? octets.subarray(udhLength(octets))
|
|
: octets;
|
|
|
|
return encodings.LATIN1.decode(body);
|
|
}
|
|
|
|
function receiptId(
|
|
tlvId: ParamValue | undefined,
|
|
receipt: Receipt | undefined,
|
|
format: SmsIdFormat,
|
|
): string | undefined {
|
|
// SMPP 3.4 5.3.2.26: the TLV is the id the submit_sm_resp carried, where the body's is a rendering.
|
|
const fromTlv = nonEmptyText(tlvId);
|
|
|
|
if (fromTlv !== undefined) return normaliseSmsId(fromTlv, format.submitResp);
|
|
|
|
const fromBody = nonEmptyText(receipt?.id);
|
|
|
|
return fromBody === undefined ? undefined : normaliseSmsId(fromBody, format.receipt);
|
|
}
|
|
|
|
function isMessageState(name: string | undefined): name is MessageState {
|
|
return name !== undefined && name in consts.MESSAGE_STATE;
|
|
}
|
|
|
|
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
|
|
function receiptStatus(
|
|
tlvState: ParamValue | undefined,
|
|
receipt: Receipt | undefined,
|
|
): { statusId: number; statusMsg: MessageState | undefined } {
|
|
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
|
|
|
|
if (typeof tlvState !== 'number') {
|
|
return { statusId: consts.MESSAGE_STATE[scraped ?? 'UNKNOWN'], statusMsg: scraped };
|
|
}
|
|
|
|
const named = constsById.MESSAGE_STATE?.[tlvState];
|
|
|
|
return { statusId: tlvState, statusMsg: isMessageState(named) ? named : scraped };
|
|
}
|
|
|
|
/** The delivery report a deliver_sm or data_sm carries, or nothing where it carries a message. */
|
|
export function dlrFromPdu(pduObj: PduObject, format: SmsIdFormat = {}): Dlr | undefined {
|
|
const type = messageType(pduObj);
|
|
|
|
if (type === 'other') return undefined;
|
|
|
|
const body = receiptBody(pduObj);
|
|
const receipt = body === '' ? undefined : parseReceipt(body);
|
|
const smsId = receiptId(pduObj.tlvs.receipted_message_id?.tagValue, receipt, format);
|
|
const { statusId, statusMsg } = receiptStatus(pduObj.tlvs.message_state?.tagValue, receipt);
|
|
|
|
if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined;
|
|
|
|
const state = statusMsg ?? 'UNKNOWN';
|
|
|
|
return {
|
|
doneDate: receiptDate(receipt?.doneDate),
|
|
errorCode: receipt?.err,
|
|
intermediate: type === 'intermediate' || transientStates.includes(state),
|
|
receipt,
|
|
smsId,
|
|
statusId,
|
|
statusMsg: state,
|
|
};
|
|
}
|