import type { MessageState } from './defs/constants.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject } from './pdu.ts'; import { consts, constsById, messageTypeOf } from './defs/constants.ts'; import { decodeMessage } from './message.ts'; import { paramNumber, paramText } from './defs/types.ts'; /** * The seven-character status codes carried in a receipt's `stat:` field, mapped to the * message_state values they correspond to. */ const receiptStates: Record = { ACCEPTD: 'ACCEPTED', DELETED: 'DELETED', DELIVRD: 'DELIVERED', ENROUTE: 'ENROUTE', EXPIRED: 'EXPIRED', 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 = { 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; receipt: Receipt | undefined; smsId: string | undefined; statusId: number; statusMsg: MessageState; }; const field = (name: string) => new RegExp(`\\b${name}:([^ ]*)`, 'i'); const patterns = { dlvrd: field('dlvrd'), doneDate: /\bdone date:([^ ]*)/i, err: field('err'), id: field('id'), stat: field('stat'), sub: field('sub'), submitDate: /\bsubmit date:([^ ]*)/i, text: /\btext:(.*)$/i, }; function toNumber(value: string | undefined): number | undefined { if (value === undefined) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } /** Delivery receipt dates are YYMMDDhhmm, sometimes with seconds. */ function receiptDate(value: string | undefined): Date | undefined { if (value === undefined) return undefined; const match = /^(\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 = Math.floor(new Date().getUTCFullYear() / 100) * 100; const date = new Date(Date.UTC( century + Number(years), Number(months) - 1, Number(days), Number(hours), Number(minutes), Number(seconds ?? 0), )); // Date.UTC rolls 31 February over into March rather than refusing it. const rolled = 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 = 'other' | 'receipt' | 'unmarked'; /** The message types the spec names that are not receipts. It reserves the remaining ten. */ const notReceiptTypes: number[] = [ consts.ESM_CLASS.CONVERSATION_ABORT, consts.ESM_CLASS.DELIVERY_ACKNOWLEDGEMENT, consts.ESM_CLASS.INTERMEDIATE_DELIVERY, 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 (notReceiptTypes.includes(type)) return 'other'; return nonEmptyText(pduObj.tlvs.receipted_message_id?.tagValue) === undefined ? 'unmarked' : 'receipt'; } /** A UDH-carrying short_message reaches here as a buffer, header and all. */ function receiptBody(pduObj: PduObject): string { const message = pduObj.params.short_message; if (!Buffer.isBuffer(message)) return paramText(message); return decodeMessage( message, paramNumber(pduObj.params.data_coding, 0), paramNumber(pduObj.params.esm_class, 0), ).message; } function receiptId(tlvId: ParamValue | undefined, receipt: Receipt | undefined): string | undefined { return nonEmptyText(tlvId) ?? nonEmptyText(receipt?.id); } 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 carries, or nothing when it carries a message instead. */ export function dlrFromPdu(pduObj: PduObject): 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); const { statusId, statusMsg } = receiptStatus(pduObj.tlvs.message_state?.tagValue, receipt); if (type === 'unmarked' && (smsId === undefined || statusMsg === undefined)) return undefined; return { doneDate: receiptDate(receipt?.doneDate), errorCode: receipt?.err, receipt, smsId, statusId, statusMsg: statusMsg ?? 'UNKNOWN', }; }