Add session, client and server with the promise result API
This commit is contained in:
+170
@@ -0,0 +1,170 @@
|
||||
import type { ConnectionOptions } from 'node:tls';
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { Session } from './session.ts';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import { connect as tlsConnect } from 'node:tls';
|
||||
import { silentLog } from './log.ts';
|
||||
|
||||
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
|
||||
|
||||
export type ClientOptions = {
|
||||
addressRange?: string;
|
||||
addrNpi?: number;
|
||||
addrTon?: number;
|
||||
bindType?: BindType;
|
||||
enquireLinkInterval?: number;
|
||||
host?: string;
|
||||
interfaceVersion?: number;
|
||||
log?: LogInt;
|
||||
maxOutstanding?: number;
|
||||
password?: string;
|
||||
port?: number;
|
||||
reconnect?: { maxDelay?: number; minDelay?: number };
|
||||
responseTimeout?: number;
|
||||
signal?: AbortSignal;
|
||||
systemType?: string;
|
||||
tls?: ConnectionOptions | boolean;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
bindType: 'transceiver',
|
||||
enquireLinkInterval: 20_000,
|
||||
host: 'localhost',
|
||||
/** SMPP 5.0. 0.4.0 declared 0x00 because its per-parameter default was never applied. */
|
||||
interfaceVersion: 0x50,
|
||||
password: 'pass',
|
||||
port: 2775,
|
||||
username: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Opens the socket. 0.4.0 built a bare `new tls.Socket()` for `tls: true`, which never performs a
|
||||
* handshake, so those connections were not encrypted at all.
|
||||
*/
|
||||
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
return new Promise(resolve => {
|
||||
const host = options.host ?? defaults.host;
|
||||
const port = options.port ?? defaults.port;
|
||||
const signal = options.signal;
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
resolve({ err: new Error('Aborted before connecting') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sock = options.tls === undefined || options.tls === false
|
||||
? netConnect({ host, port })
|
||||
: tlsConnect({
|
||||
host,
|
||||
port,
|
||||
...(typeof options.tls === 'object' ? options.tls : {}),
|
||||
});
|
||||
|
||||
const settle = (result: Result<{ sock: Socket }>): void => {
|
||||
sock.removeListener('error', onError);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
function onError(err: Error): void {
|
||||
settle({ err });
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
sock.destroy();
|
||||
settle({ err: new Error('Aborted while connecting') });
|
||||
}
|
||||
|
||||
sock.once('error', onError);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
sock.once(options.tls === undefined || options.tls === false ? 'connect' : 'secureConnect', () => {
|
||||
settle({ sock });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> {
|
||||
const bindType = options.bindType ?? defaults.bindType;
|
||||
const systemId = options.username ?? defaults.username;
|
||||
const sent = await session.send({
|
||||
cmdName: `bind_${bindType}`,
|
||||
params: {
|
||||
address_range: options.addressRange ?? '',
|
||||
addr_npi: options.addrNpi ?? 0,
|
||||
addr_ton: options.addrTon ?? 0,
|
||||
interface_version: options.interfaceVersion ?? defaults.interfaceVersion,
|
||||
password: options.password ?? defaults.password,
|
||||
system_id: systemId,
|
||||
system_type: options.systemType ?? '',
|
||||
},
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
|
||||
if (sent.err) return { err: sent.err };
|
||||
|
||||
if (sent.pduObj.cmdStatus !== 'ESME_ROK') {
|
||||
session.log.info('client - bind refused', {
|
||||
cmdStatus: sent.pduObj.cmdStatus ?? sent.pduObj.cmdStatusId,
|
||||
systemId,
|
||||
});
|
||||
|
||||
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
|
||||
}
|
||||
|
||||
session.loggedIn = true;
|
||||
session.log.info('client - bound', { bindType, systemId });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Connects to an SMSC and binds. */
|
||||
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
|
||||
const log = options.log ?? silentLog;
|
||||
const opened = await openSocket(options);
|
||||
|
||||
if (opened.err) {
|
||||
log.warn('client - could not connect', {
|
||||
host: options.host ?? defaults.host,
|
||||
message: opened.err.message,
|
||||
port: options.port ?? defaults.port,
|
||||
});
|
||||
|
||||
return { err: opened.err };
|
||||
}
|
||||
|
||||
const session = new Session({
|
||||
enquireLinkInterval: options.enquireLinkInterval ?? defaults.enquireLinkInterval,
|
||||
log,
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
responseTimeout: options.responseTimeout,
|
||||
sock: opened.sock,
|
||||
...(options.reconnect
|
||||
? {
|
||||
reconnect: {
|
||||
connect: () => openSocket(options),
|
||||
maxDelay: options.reconnect.maxDelay,
|
||||
minDelay: options.reconnect.minDelay,
|
||||
onConnected: reconnected => bind(reconnected, options),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const bound = await bind(session, options);
|
||||
|
||||
if (bound.err) {
|
||||
session.close();
|
||||
|
||||
return { err: bound.err };
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => { session.close(); }, { once: true });
|
||||
}
|
||||
|
||||
return { session };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cmds, cmdsById } from './commands.ts';
|
||||
import { consts, constsById } from './constants.ts';
|
||||
import { encodings } from './encodings.ts';
|
||||
import { errors, errorsById } from './errors.ts';
|
||||
import { tlvs, tlvsById } from './tlvs.ts';
|
||||
import { types } from './types.ts';
|
||||
|
||||
export const defs = {
|
||||
cmds,
|
||||
cmdsById,
|
||||
consts,
|
||||
constsById,
|
||||
encodings,
|
||||
errors,
|
||||
errorsById,
|
||||
tlvs,
|
||||
tlvsById,
|
||||
types,
|
||||
};
|
||||
@@ -24,6 +24,15 @@ export type WireType<T extends ParamValue = ParamValue> = {
|
||||
write: (value: ParamValue, buffer: Buffer, offset: number) => VoidResult;
|
||||
};
|
||||
|
||||
/** Renders a parameter as text without ever falling back to "[object Object]". */
|
||||
export function paramText(value: ParamValue | undefined): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number') return value.toString();
|
||||
if (Buffer.isBuffer(value)) return value.toString('ascii');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
|
||||
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
||||
return new Error(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export { client } from './client.ts';
|
||||
export { server, SmppServer } from './server.ts';
|
||||
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 { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||
export { types } from './defs/types.ts';
|
||||
|
||||
export {
|
||||
isCommand,
|
||||
isResp,
|
||||
maxPduLength,
|
||||
maxSeqNr,
|
||||
objToPdu,
|
||||
pduReturn,
|
||||
pduToObj,
|
||||
} from './pdu.ts';
|
||||
|
||||
export {
|
||||
bitCount,
|
||||
decodeMessage,
|
||||
encodeMessage,
|
||||
smppDate,
|
||||
smppTime,
|
||||
splitMessage,
|
||||
} from './message.ts';
|
||||
|
||||
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
|
||||
export { concatInfo } from './udh.ts';
|
||||
export { PduFramer } from './pdu-framer.ts';
|
||||
export { uuidv7 } from './uuid.ts';
|
||||
|
||||
export type { BindType, ClientOptions } from './client.ts';
|
||||
export type { Dlr, Receipt } from './dlr.ts';
|
||||
export type { Sms, SmsInput } from './sms.ts';
|
||||
export type { ConcatInfo } from './udh.ts';
|
||||
export type { Result, VoidResult } from './result.ts';
|
||||
export type {
|
||||
AuthenticateInput,
|
||||
AuthenticateResult,
|
||||
ServerEvents,
|
||||
ServerOptions,
|
||||
} from './server.ts';
|
||||
export type {
|
||||
MessageDlr,
|
||||
ReconnectOptions,
|
||||
SendOptions,
|
||||
SendSmsOptions,
|
||||
SessionEvents,
|
||||
SessionOptions,
|
||||
} from './session.ts';
|
||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
||||
export type { ConstGroup, MessageState } from './defs/constants.ts';
|
||||
export type { Encoding, EncodingName } from './defs/encodings.ts';
|
||||
export type { ErrorName } from './defs/errors.ts';
|
||||
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
export type { SplitOptions } from './message.ts';
|
||||
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
|
||||
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
|
||||
|
||||
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
|
||||
export { defs } from './defs/index.ts';
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import { Log } from '@larvit/log';
|
||||
|
||||
/** The default: a library that says nothing unless the application asks it to. */
|
||||
export const silentLog: LogInt = new Log({ logLevel: 'none' });
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { Result } from './result.ts';
|
||||
import type { Server as NetServer, Socket } from 'node:net';
|
||||
import type { TlsOptions } from 'node:tls';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session } from './session.ts';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import { createServer as createTlsServer } from 'node:tls';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { silentLog } from './log.ts';
|
||||
|
||||
export type AuthenticateResult = { userData?: unknown } | boolean;
|
||||
|
||||
export type AuthenticateInput = {
|
||||
password: string;
|
||||
session: Session;
|
||||
systemId: string;
|
||||
systemType: string;
|
||||
};
|
||||
|
||||
export type ServerOptions = {
|
||||
authenticate?: (input: AuthenticateInput) => Promise<AuthenticateResult> | AuthenticateResult;
|
||||
host?: string;
|
||||
idleTimeout?: number;
|
||||
log?: LogInt;
|
||||
maxOutstanding?: number;
|
||||
maxReassembly?: number;
|
||||
port?: number;
|
||||
reassemblyTimeout?: number;
|
||||
responseTimeout?: number;
|
||||
signal?: AbortSignal;
|
||||
tls?: TlsOptions | boolean;
|
||||
};
|
||||
|
||||
export type ServerEvents = {
|
||||
serverError: [Error];
|
||||
session: [Session];
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
idleTimeout: 40_000,
|
||||
port: 2775,
|
||||
};
|
||||
|
||||
const bindCommands = ['bind_receiver', 'bind_transceiver', 'bind_transmitter'];
|
||||
|
||||
/** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */
|
||||
export class SmppServer extends EventEmitter<ServerEvents> {
|
||||
readonly sessions = new Set<Session>();
|
||||
|
||||
private readonly server: NetServer;
|
||||
|
||||
constructor(server: NetServer) {
|
||||
super();
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
/** The port actually bound, which matters when 0 was requested. */
|
||||
get port(): number {
|
||||
const address = this.server.address();
|
||||
|
||||
return typeof address === 'object' && address !== null ? address.port : 0;
|
||||
}
|
||||
|
||||
/** Stops listening and closes every live session. */
|
||||
close(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
for (const session of this.sessions) {
|
||||
session.close();
|
||||
}
|
||||
|
||||
this.sessions.clear();
|
||||
this.server.close(() => { resolve(); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
session: Session,
|
||||
pduObj: PduObject,
|
||||
options: ServerOptions,
|
||||
): Promise<boolean> {
|
||||
if (!options.authenticate) return true;
|
||||
|
||||
const params = pduObj.params;
|
||||
const result = await options.authenticate({
|
||||
password: typeof params.password === 'string' ? params.password : '',
|
||||
session,
|
||||
systemId: typeof params.system_id === 'string' ? params.system_id : '',
|
||||
systemType: typeof params.system_type === 'string' ? params.system_type : '',
|
||||
});
|
||||
|
||||
if (result === false) return false;
|
||||
|
||||
if (result !== true && typeof result === 'object') {
|
||||
session.userData = result.userData;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles everything a peer may send before it is bound. Returns true when it has answered, so the
|
||||
* session leaves the PDU alone.
|
||||
*/
|
||||
async function onRequest(
|
||||
session: Session,
|
||||
pduObj: PduObject,
|
||||
options: ServerOptions,
|
||||
): Promise<boolean> {
|
||||
const log = options.log ?? silentLog;
|
||||
|
||||
if (session.loggedIn || pduObj.cmdName === 'unbind') return false;
|
||||
|
||||
if (!bindCommands.includes(pduObj.cmdName)) {
|
||||
log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!await authenticate(session, pduObj, options)) {
|
||||
log.info('server - bind refused', { systemId: paramText(pduObj.params.system_id) });
|
||||
await session.sendReturn(pduObj, 'ESME_RBINDFAIL');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
session.loggedIn = true;
|
||||
await session.sendReturn(pduObj);
|
||||
log.verbose('server - bound', { systemId: paramText(pduObj.params.system_id) });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void {
|
||||
const log = options.log ?? silentLog;
|
||||
const session = new Session({
|
||||
idleTimeout: options.idleTimeout ?? defaults.idleTimeout,
|
||||
log,
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
maxReassembly: options.maxReassembly,
|
||||
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
|
||||
reassemblyTimeout: options.reassemblyTimeout,
|
||||
responseTimeout: options.responseTimeout,
|
||||
sock,
|
||||
});
|
||||
|
||||
server.sessions.add(session);
|
||||
session.on('close', () => server.sessions.delete(session));
|
||||
|
||||
log.verbose('server - incoming connection', {
|
||||
remoteAddress: sock.remoteAddress ?? '',
|
||||
remotePort: sock.remotePort ?? 0,
|
||||
});
|
||||
|
||||
server.emit('session', session);
|
||||
}
|
||||
|
||||
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
|
||||
export function server(options: ServerOptions = {}): Promise<Result<{ server: SmppServer }>> {
|
||||
return new Promise(resolve => {
|
||||
const log = options.log ?? silentLog;
|
||||
const port = options.port ?? defaults.port;
|
||||
const useTls = options.tls !== undefined && options.tls !== false;
|
||||
const listener = useTls
|
||||
? createTlsServer(typeof options.tls === 'object' ? options.tls : {})
|
||||
: createNetServer();
|
||||
const smpp = new SmppServer(listener);
|
||||
|
||||
listener.on(useTls ? 'secureConnection' : 'connection', (sock: Socket) => {
|
||||
onConnection(sock, options, smpp);
|
||||
});
|
||||
|
||||
const onStartupError = (err: Error): void => {
|
||||
listener.removeListener('error', onStartupError);
|
||||
log.warn('server - could not listen', { message: err.message, port });
|
||||
resolve({ err });
|
||||
};
|
||||
|
||||
listener.once('error', onStartupError);
|
||||
|
||||
listener.listen(port, options.host, () => {
|
||||
listener.removeListener('error', onStartupError);
|
||||
|
||||
// Past startup, a listener error is a runtime event, not a failed start.
|
||||
listener.on('error', (err: Error) => {
|
||||
log.warn('server - error', { message: err.message });
|
||||
smpp.emit('serverError', err);
|
||||
});
|
||||
|
||||
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true });
|
||||
}
|
||||
|
||||
resolve({ server: smpp });
|
||||
});
|
||||
});
|
||||
}
|
||||
+738
@@ -0,0 +1,738 @@
|
||||
import type { Dlr } from './dlr.ts';
|
||||
import type { EncodingName } from './defs/encodings.ts';
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Sms } from './sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PduFramer } from './pdu-framer.ts';
|
||||
import { concatInfo } from './udh.ts';
|
||||
import { consts } from './defs/constants.ts';
|
||||
import { createSms } from './sms.ts';
|
||||
import { decodeMessage, smppTime, splitMessage } from './message.ts';
|
||||
import { detect } from './defs/encodings.ts';
|
||||
import { dlrFromPdu } from './dlr.ts';
|
||||
import { isResp, maxSeqNr, objToPdu, pduReturn, pduToObj } from './pdu.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { silentLog } from './log.ts';
|
||||
|
||||
export type MessageDlr = Dlr & { segments: Dlr[] };
|
||||
|
||||
export type SessionEvents = {
|
||||
close: [];
|
||||
data: [Buffer];
|
||||
dlr: [Dlr, PduObject];
|
||||
incomingPdu: [Buffer];
|
||||
incomingPduObj: [PduObject];
|
||||
messageDlr: [MessageDlr];
|
||||
reconnected: [];
|
||||
sessionError: [Error];
|
||||
sms: [Sms];
|
||||
};
|
||||
|
||||
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||
|
||||
/**
|
||||
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
|
||||
* supplies how to open a socket and what to do once it is open (bind, for a client).
|
||||
*/
|
||||
export type ReconnectOptions = {
|
||||
connect: () => Promise<Result<{ sock: Socket }>>;
|
||||
maxDelay?: number | undefined;
|
||||
minDelay?: number | undefined;
|
||||
onConnected: (session: Session) => Promise<VoidResult>;
|
||||
};
|
||||
|
||||
export type SendSmsOptions = {
|
||||
dlr?: boolean;
|
||||
destinationAddrNpi?: number;
|
||||
destinationAddrTon?: number;
|
||||
encoding?: EncodingName;
|
||||
flash?: boolean;
|
||||
from: string;
|
||||
message: string;
|
||||
scheduleDeliveryTime?: Date | number | string;
|
||||
sourceAddrNpi?: number;
|
||||
sourceAddrTon?: number;
|
||||
to: string;
|
||||
validityPeriod?: Date | number | string;
|
||||
};
|
||||
|
||||
export type SessionOptions = {
|
||||
enquireLinkInterval?: number | undefined;
|
||||
idleTimeout?: number | undefined;
|
||||
log?: LogInt | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
/**
|
||||
* First refusal on every incoming request. Returning true means the hook answered it and the
|
||||
* built-in handling is skipped — this is how the server owns bind without the session also
|
||||
* replying "invalid command".
|
||||
*/
|
||||
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
reconnect?: ReconnectOptions | undefined;
|
||||
responseTimeout?: number | undefined;
|
||||
sock: Socket;
|
||||
};
|
||||
|
||||
type Pending = {
|
||||
settle: (result: Result<{ pduObj: PduObject }>) => void;
|
||||
};
|
||||
|
||||
type Reassembly = {
|
||||
parts: Map<number, PduObject>;
|
||||
timer: NodeJS.Timeout;
|
||||
total: number;
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
maxDelay: 30_000,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
minDelay: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
responseTimeout: 30_000,
|
||||
};
|
||||
|
||||
/** Alphanumeric senders must be TON 5; 0.4.0 sent everything as TON 1 (international). */
|
||||
function addressTon(address: string): number {
|
||||
return /^\+?\d+$/.test(address) ? consts.TON.INTERNATIONAL : consts.TON.ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
|
||||
if (!flash) return consts.ENCODING[encoding];
|
||||
|
||||
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
|
||||
return encoding === 'UCS2' ? 0x18 : 0x10;
|
||||
}
|
||||
|
||||
export class Session extends EventEmitter<SessionEvents> {
|
||||
/** Replaced on reconnect, so hold the session rather than this. */
|
||||
sock: Socket;
|
||||
readonly log: LogInt;
|
||||
|
||||
loggedIn = false;
|
||||
userData: unknown = undefined;
|
||||
|
||||
private framer = new PduFramer();
|
||||
private readonly options: SessionOptions;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly reassembly = new Map<string, Reassembly>();
|
||||
private readonly segmentDlrs = new Map<string, Map<number, Dlr>>();
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
|
||||
private closed = false;
|
||||
private concatReference = 0;
|
||||
private enquireLinkTimer: NodeJS.Timeout | undefined;
|
||||
private idleTimer: NodeJS.Timeout | undefined;
|
||||
private inFlight = 0;
|
||||
private ourSeqNr = 1;
|
||||
private reconnectDelay: number;
|
||||
private reconnectTimer: NodeJS.Timeout | undefined;
|
||||
private stopped = false;
|
||||
|
||||
constructor(options: SessionOptions) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
this.log = options.log ?? silentLog;
|
||||
this.sock = options.sock;
|
||||
this.reconnectDelay = options.reconnect?.minDelay ?? defaults.minDelay;
|
||||
|
||||
this.attach(options.sock);
|
||||
this.resetTimers();
|
||||
}
|
||||
|
||||
/** Wires a freshly opened socket into this session, replacing any previous one. */
|
||||
private attach(sock: Socket): void {
|
||||
this.sock = sock;
|
||||
this.framer = new PduFramer();
|
||||
this.closed = false;
|
||||
|
||||
sock.on('data', chunk => { this.onData(chunk); });
|
||||
sock.on('close', () => { this.onClose(); });
|
||||
sock.on('error', err => {
|
||||
this.log.warn('session - socket error', { message: err.message });
|
||||
this.emit('sessionError', err);
|
||||
this.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends a request and resolves with the peer's response. */
|
||||
async send(
|
||||
input: PduObjectInput,
|
||||
options: SendOptions = {},
|
||||
): Promise<Result<{ pduObj: PduObject }>> {
|
||||
if (input.cmdName.endsWith('_resp')) {
|
||||
return { err: new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) };
|
||||
}
|
||||
|
||||
if (this.closed) return { err: new Error('Session is closed') };
|
||||
|
||||
await this.acquire();
|
||||
|
||||
try {
|
||||
const seqNr = this.nextSeqNr();
|
||||
const built = objToPdu({ ...input, seqNr });
|
||||
|
||||
if (built.err) return { err: built.err };
|
||||
|
||||
const response = this.awaitResponse(seqNr, options.signal);
|
||||
const written = this.write(built.buffer);
|
||||
|
||||
if (written.err) {
|
||||
this.settle(seqNr, { err: written.err });
|
||||
|
||||
return { err: written.err };
|
||||
}
|
||||
|
||||
return await response;
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers a request the peer sent us. Responses are never waited on. */
|
||||
async sendReturn(
|
||||
pdu: PduObject,
|
||||
status: ErrorName = 'ESME_ROK',
|
||||
params: Record<string, ParamValue> = {},
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
): Promise<VoidResult> {
|
||||
const built = pduReturn(pdu, status, params, tlvs);
|
||||
|
||||
if (built.err) return { err: built.err };
|
||||
|
||||
return Promise.resolve(this.write(built.buffer));
|
||||
}
|
||||
|
||||
async sendSms(
|
||||
sms: SendSmsOptions,
|
||||
options: SendOptions = {},
|
||||
): Promise<Result<{ pduObjs: PduObject[]; smsIds: string[] }>> {
|
||||
const encoding = sms.encoding ?? detect(sms.message);
|
||||
const segments = splitMessage(sms.message, {
|
||||
encoding,
|
||||
reference: this.nextConcatReference(),
|
||||
});
|
||||
const pduObjs: PduObject[] = [];
|
||||
const smsIds: string[] = [];
|
||||
|
||||
this.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
|
||||
|
||||
// Segments go out together rather than one-after-a-response: a receiver that waits for every
|
||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
||||
const sent = await Promise.all(segments.map(segment => {
|
||||
const params: Record<string, ParamValue> = {
|
||||
data_coding: dataCodingFor(encoding, sms.flash === true),
|
||||
destination_addr: sms.to,
|
||||
dest_addr_npi: sms.destinationAddrNpi ?? 0,
|
||||
dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to),
|
||||
short_message: segment,
|
||||
sm_length: segment.length,
|
||||
source_addr: sms.from,
|
||||
source_addr_npi: sms.sourceAddrNpi ?? 0,
|
||||
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
|
||||
};
|
||||
|
||||
if (segments.length > 1) params.esm_class = consts.ESM_CLASS.UDH_INDICATOR;
|
||||
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
|
||||
if (sms.validityPeriod !== undefined) {
|
||||
params.validity_period = smppTime.encode(sms.validityPeriod);
|
||||
}
|
||||
if (sms.scheduleDeliveryTime !== undefined) {
|
||||
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
|
||||
}
|
||||
|
||||
return this.send({ cmdName: 'submit_sm', params }, options);
|
||||
}));
|
||||
|
||||
for (const one of sent) {
|
||||
if (one.err) return { err: one.err };
|
||||
|
||||
pduObjs.push(one.pduObj);
|
||||
smsIds.push(paramText(one.pduObj.params.message_id));
|
||||
}
|
||||
|
||||
return { pduObjs, smsIds };
|
||||
}
|
||||
|
||||
/** Unbinds politely, then closes. */
|
||||
async unbind(): Promise<VoidResult> {
|
||||
const sent = await this.send({ cmdName: 'unbind' });
|
||||
|
||||
this.close();
|
||||
|
||||
return sent.err ? { err: sent.err } : {};
|
||||
}
|
||||
|
||||
/** Closes for good. A session closed this way never reconnects. */
|
||||
close(): void {
|
||||
this.stopped = true;
|
||||
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
|
||||
this.reconnectTimer = undefined;
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
this.clearTimers();
|
||||
|
||||
for (const [seqNr] of this.pending) {
|
||||
this.settle(seqNr, { err: new Error('Session closed before a response arrived') });
|
||||
}
|
||||
|
||||
for (const group of this.reassembly.values()) {
|
||||
clearTimeout(group.timer);
|
||||
}
|
||||
|
||||
this.reassembly.clear();
|
||||
this.sock.destroy();
|
||||
}
|
||||
|
||||
private nextSeqNr(): number {
|
||||
const seqNr = this.ourSeqNr;
|
||||
|
||||
this.ourSeqNr = this.ourSeqNr >= maxSeqNr ? 1 : this.ourSeqNr + 1;
|
||||
|
||||
return seqNr;
|
||||
}
|
||||
|
||||
private nextConcatReference(): number {
|
||||
this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1;
|
||||
|
||||
return this.concatReference;
|
||||
}
|
||||
|
||||
private async acquire(): Promise<void> {
|
||||
const limit = this.options.maxOutstanding ?? defaults.maxOutstanding;
|
||||
|
||||
if (this.inFlight < limit) {
|
||||
this.inFlight++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>(resolve => this.waiting.push(resolve));
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
const next = this.waiting.shift();
|
||||
|
||||
if (next) {
|
||||
next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlight--;
|
||||
}
|
||||
|
||||
private write(pdu: Buffer): VoidResult {
|
||||
if (this.sock.destroyed) {
|
||||
return { err: new Error('Socket is closed') };
|
||||
}
|
||||
|
||||
this.sock.write(pdu);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private awaitResponse(
|
||||
seqNr: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<{ pduObj: PduObject }>> {
|
||||
return new Promise(resolve => {
|
||||
const timeout = this.options.responseTimeout ?? defaults.responseTimeout;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const onAbort = (): void => {
|
||||
this.settle(seqNr, { err: new Error('Aborted before a response arrived') });
|
||||
};
|
||||
|
||||
const settle = (result: Result<{ pduObj: PduObject }>): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
this.pending.delete(seqNr);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
this.pending.set(seqNr, { settle });
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
settle({ err: new Error('Aborted before a response arrived') });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
if (timeout > 0) {
|
||||
timer = setTimeout(() => {
|
||||
this.log.warn('session - no response before the timeout', { seqNr, timeout });
|
||||
this.settle(seqNr, { err: new Error(`No response to seqNr ${String(seqNr)}`) });
|
||||
}, timeout);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private settle(seqNr: number, result: Result<{ pduObj: PduObject }>): void {
|
||||
this.pending.get(seqNr)?.settle(result);
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer): void {
|
||||
this.emit('data', chunk);
|
||||
this.resetTimers();
|
||||
this.framer.push(chunk);
|
||||
|
||||
const framed = this.framer.next();
|
||||
|
||||
if (framed.err) {
|
||||
this.log.warn('session - unusable stream, closing', { message: framed.err.message });
|
||||
this.emit('sessionError', framed.err);
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pdu of framed.pdus) {
|
||||
this.emit('incomingPdu', pdu);
|
||||
|
||||
const parsed = pduToObj(pdu);
|
||||
|
||||
if (parsed.err) {
|
||||
this.log.warn('session - could not parse an incoming PDU, closing', {
|
||||
message: parsed.err.message,
|
||||
});
|
||||
this.emit('sessionError', parsed.err);
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatch(parsed.pduObj);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(pduObj: PduObject): void {
|
||||
if (isResp(pduObj)) {
|
||||
const pending = this.pending.get(pduObj.seqNr);
|
||||
|
||||
if (!pending) {
|
||||
this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
pending.settle({ pduObj });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('incomingPduObj', pduObj);
|
||||
void this.handle(pduObj);
|
||||
}
|
||||
|
||||
private async handle(pduObj: PduObject): Promise<void> {
|
||||
const onRequest = this.options.onRequest;
|
||||
|
||||
if (onRequest && await onRequest(this, pduObj)) return;
|
||||
|
||||
if (pduObj.cmdName === 'enquire_link') {
|
||||
await this.sendReturn(pduObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pduObj.cmdName === 'unbind') {
|
||||
await this.sendReturn(pduObj);
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pduObj.cmdName === 'submit_sm') {
|
||||
this.onSubmitSm(pduObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (pduObj.cmdName === 'deliver_sm') {
|
||||
await this.onDeliverSm(pduObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
||||
await this.sendReturn(pduObj, 'ESME_RINVCMDID');
|
||||
}
|
||||
|
||||
private onSubmitSm(pduObj: PduObject): void {
|
||||
const message = pduObj.params.short_message;
|
||||
const esmClass = pduObj.params.esm_class;
|
||||
const hasUdh = typeof esmClass === 'number'
|
||||
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
|
||||
|
||||
if (!hasUdh || !Buffer.isBuffer(message)) {
|
||||
this.emitSms([pduObj]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const concat = concatInfo(message);
|
||||
|
||||
if (!concat) {
|
||||
this.emitSms([pduObj]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.collectSegment(pduObj, concat);
|
||||
}
|
||||
|
||||
private collectSegment(
|
||||
pduObj: PduObject,
|
||||
concat: { part: number; reference: number; total: number },
|
||||
): void {
|
||||
const key = [
|
||||
paramText(pduObj.params.source_addr),
|
||||
paramText(pduObj.params.destination_addr),
|
||||
String(concat.reference),
|
||||
].join('_');
|
||||
|
||||
let group = this.reassembly.get(key);
|
||||
|
||||
if (!group) {
|
||||
const limit = this.options.maxReassembly ?? defaults.maxReassembly;
|
||||
|
||||
if (this.reassembly.size >= limit) {
|
||||
const oldest = this.reassembly.keys().next();
|
||||
|
||||
if (!oldest.done) {
|
||||
this.log.warn('session - reassembly buffer full, dropping the oldest message', {
|
||||
limit,
|
||||
});
|
||||
this.dropReassembly(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.log.info('session - incomplete message expired', { key, total: concat.total });
|
||||
this.dropReassembly(key);
|
||||
}, this.options.reassemblyTimeout ?? defaults.reassemblyTimeout);
|
||||
|
||||
timer.unref();
|
||||
group = { parts: new Map(), timer, total: concat.total };
|
||||
this.reassembly.set(key, group);
|
||||
}
|
||||
|
||||
group.parts.set(concat.part, pduObj);
|
||||
|
||||
if (group.parts.size < group.total) return;
|
||||
|
||||
clearTimeout(group.timer);
|
||||
this.reassembly.delete(key);
|
||||
|
||||
const ordered = [...group.parts.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, part]) => part);
|
||||
|
||||
this.emitSms(ordered);
|
||||
}
|
||||
|
||||
private dropReassembly(key: string): void {
|
||||
const group = this.reassembly.get(key);
|
||||
|
||||
if (!group) return;
|
||||
|
||||
clearTimeout(group.timer);
|
||||
this.reassembly.delete(key);
|
||||
}
|
||||
|
||||
private emitSms(pduObjs: PduObject[]): void {
|
||||
const first = pduObjs[0];
|
||||
|
||||
if (!first) return;
|
||||
|
||||
let message = '';
|
||||
|
||||
for (const pduObj of pduObjs) {
|
||||
const part = pduObj.params.short_message;
|
||||
const dataCoding = pduObj.params.data_coding;
|
||||
const esmClass = pduObj.params.esm_class;
|
||||
|
||||
message += Buffer.isBuffer(part)
|
||||
? decodeMessage(
|
||||
part,
|
||||
typeof dataCoding === 'number' ? dataCoding : 0,
|
||||
typeof esmClass === 'number' ? esmClass : 0,
|
||||
).message
|
||||
: paramText(part);
|
||||
}
|
||||
|
||||
this.emit('sms', createSms({
|
||||
from: paramText(first.params.source_addr),
|
||||
message,
|
||||
pduObjs,
|
||||
session: this,
|
||||
to: paramText(first.params.destination_addr),
|
||||
}));
|
||||
}
|
||||
|
||||
private async onDeliverSm(pduObj: PduObject): Promise<void> {
|
||||
const dlr = dlrFromPdu(pduObj);
|
||||
|
||||
if (!dlr) {
|
||||
this.log.info('session - deliver_sm carries no delivery report', { seqNr: pduObj.seqNr });
|
||||
await this.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('dlr', dlr, pduObj);
|
||||
this.collectSegmentDlr(dlr);
|
||||
await this.sendReturn(pduObj);
|
||||
}
|
||||
|
||||
/** Segment ids look like `<uuid>-<n>`; once every segment is in, report on the whole message. */
|
||||
private collectSegmentDlr(dlr: Dlr): void {
|
||||
const match = /^(.*)-(\d+)$/.exec(dlr.smsId);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
const [, smsId, part] = match;
|
||||
|
||||
if (smsId === undefined || part === undefined) return;
|
||||
|
||||
const segments = this.segmentDlrs.get(smsId) ?? new Map<number, Dlr>();
|
||||
|
||||
segments.set(Number(part), dlr);
|
||||
this.segmentDlrs.set(smsId, segments);
|
||||
|
||||
const highest = Math.max(...segments.keys());
|
||||
|
||||
if (segments.size < highest) return;
|
||||
|
||||
this.segmentDlrs.delete(smsId);
|
||||
|
||||
const ordered = [...segments.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
|
||||
const worst = ordered.reduce((carry, one) => (one.statusId > carry.statusId ? one : carry));
|
||||
|
||||
this.emit('messageDlr', { ...worst, segments: ordered, smsId });
|
||||
}
|
||||
|
||||
private resetTimers(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.clearTimers();
|
||||
|
||||
const { enquireLinkInterval, idleTimeout } = this.options;
|
||||
|
||||
if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) {
|
||||
this.enquireLinkTimer = setTimeout(() => {
|
||||
void this.send({ cmdName: 'enquire_link' });
|
||||
}, enquireLinkInterval);
|
||||
this.enquireLinkTimer.unref();
|
||||
}
|
||||
|
||||
if (idleTimeout !== undefined && idleTimeout > 0) {
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.log.info('session - closing an idle peer', { idleTimeout });
|
||||
this.close();
|
||||
}, idleTimeout);
|
||||
this.idleTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.enquireLinkTimer) clearTimeout(this.enquireLinkTimer);
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
|
||||
this.enquireLinkTimer = undefined;
|
||||
this.idleTimer = undefined;
|
||||
}
|
||||
|
||||
private onClose(): void {
|
||||
const wasOpen = !this.closed;
|
||||
|
||||
this.teardown();
|
||||
|
||||
if (wasOpen) this.emit('close');
|
||||
|
||||
if (!this.stopped && this.options.reconnect) this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
const reconnect = this.options.reconnect;
|
||||
|
||||
if (!reconnect) return;
|
||||
|
||||
const delay = this.reconnectDelay;
|
||||
|
||||
this.log.info('session - reconnecting after a drop', { delay });
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
void this.reconnect();
|
||||
}, delay);
|
||||
this.reconnectTimer.unref();
|
||||
|
||||
this.reconnectDelay = Math.min(delay * 2, reconnect.maxDelay ?? defaults.maxDelay);
|
||||
}
|
||||
|
||||
/** Read through a method: close() can land while a reconnect is awaiting. */
|
||||
private isStopped(): boolean {
|
||||
return this.stopped;
|
||||
}
|
||||
|
||||
private async reconnect(): Promise<void> {
|
||||
const reconnect = this.options.reconnect;
|
||||
|
||||
if (!reconnect || this.isStopped()) return;
|
||||
|
||||
const opened = await reconnect.connect();
|
||||
|
||||
if (opened.err) {
|
||||
this.log.warn('session - reconnect failed to open a socket', {
|
||||
message: opened.err.message,
|
||||
});
|
||||
this.scheduleReconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isStopped()) {
|
||||
opened.sock.destroy();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.attach(opened.sock);
|
||||
|
||||
const bound = await reconnect.onConnected(this);
|
||||
|
||||
if (bound.err) {
|
||||
this.log.warn('session - reconnect failed to bind', { message: bound.err.message });
|
||||
this.teardown();
|
||||
this.scheduleReconnect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectDelay = reconnect.minDelay ?? defaults.minDelay;
|
||||
this.resetTimers();
|
||||
this.log.info('session - reconnected');
|
||||
this.emit('reconnected');
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { MessageState } from './defs/constants.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Session } from './session.ts';
|
||||
import { consts } from './defs/constants.ts';
|
||||
import { receiptCodes } from './dlr.ts';
|
||||
import { smppDate } from './message.ts';
|
||||
import { uuidv7 } from './uuid.ts';
|
||||
|
||||
/**
|
||||
* A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying
|
||||
* every segment's PDU.
|
||||
*/
|
||||
export type Sms = {
|
||||
dlr: boolean;
|
||||
flash: boolean;
|
||||
from: string;
|
||||
message: string;
|
||||
pduObjs: PduObject[];
|
||||
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */
|
||||
sendDlr: (status?: MessageState) => Promise<Result<{ pduObjs: PduObject[] }>>;
|
||||
/** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */
|
||||
sendResp: (status?: ErrorName) => Promise<VoidResult>;
|
||||
session: Session;
|
||||
/** Generated as a UUID v7 unless the application sets its own before answering. */
|
||||
smsId: string;
|
||||
submitTime: Date;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type SmsInput = {
|
||||
from: string;
|
||||
message: string;
|
||||
pduObjs: PduObject[];
|
||||
session: Session;
|
||||
to: string;
|
||||
};
|
||||
|
||||
/** Each segment of a multipart message gets its own message_id, as a separate submit_sm must. */
|
||||
function segmentId(smsId: string, index: number, total: number): string {
|
||||
return total === 1 ? smsId : `${smsId}-${String(index + 1)}`;
|
||||
}
|
||||
|
||||
export function createSms(input: SmsInput): Sms {
|
||||
const first = input.pduObjs[0];
|
||||
const registered = first?.params.registered_delivery;
|
||||
const dataCoding = first?.params.data_coding;
|
||||
|
||||
const sms: Sms = {
|
||||
dlr: typeof registered === 'number' && registered !== 0,
|
||||
flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10,
|
||||
from: input.from,
|
||||
message: input.message,
|
||||
pduObjs: input.pduObjs,
|
||||
sendDlr: status => sendDlr(sms, status),
|
||||
sendResp: status => sendResp(sms, status),
|
||||
session: input.session,
|
||||
smsId: uuidv7(),
|
||||
submitTime: new Date(),
|
||||
to: input.to,
|
||||
};
|
||||
|
||||
return sms;
|
||||
}
|
||||
|
||||
async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise<VoidResult> {
|
||||
const total = sms.pduObjs.length;
|
||||
|
||||
if (total === 0) {
|
||||
return { err: new Error('No PDUs to answer') };
|
||||
}
|
||||
|
||||
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn(
|
||||
pduObj,
|
||||
status,
|
||||
{ message_id: segmentId(sms.smsId, index, total) },
|
||||
)));
|
||||
|
||||
return results.find(result => result.err) ?? {};
|
||||
}
|
||||
|
||||
async function sendDlr(
|
||||
sms: Sms,
|
||||
status: MessageState = 'DELIVERED',
|
||||
): Promise<Result<{ pduObjs: PduObject[] }>> {
|
||||
const statusId = consts.MESSAGE_STATE[status];
|
||||
const total = sms.pduObjs.length;
|
||||
const pduObjs: PduObject[] = [];
|
||||
|
||||
for (let index = 0; index < total; index++) {
|
||||
const smsId = segmentId(sms.smsId, index, total);
|
||||
const delivered = status === 'DELIVERED';
|
||||
const message = [
|
||||
`id:${smsId}`,
|
||||
'sub:001',
|
||||
`dlvrd:${delivered ? '001' : '000'}`,
|
||||
`submit date:${smppDate(sms.submitTime)}`,
|
||||
`done date:${smppDate(new Date())}`,
|
||||
`stat:${receiptCodes[status]}`,
|
||||
`err:${delivered ? '000' : '001'}`,
|
||||
'text:',
|
||||
].join(' ');
|
||||
|
||||
const sent = await sms.session.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: sms.from,
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
short_message: message,
|
||||
source_addr: sms.to,
|
||||
},
|
||||
tlvs: {
|
||||
message_state: { tagId: 0x0427, tagName: 'message_state', tagValue: statusId },
|
||||
receipted_message_id: {
|
||||
tagId: 0x001E,
|
||||
tagName: 'receipted_message_id',
|
||||
tagValue: smsId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (sent.err) return { err: sent.err };
|
||||
|
||||
pduObjs.push(sent.pduObj);
|
||||
}
|
||||
|
||||
return { pduObjs };
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
export type ConcatInfo = {
|
||||
part: number;
|
||||
reference: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the concatenation information element in a User Data Header, walking the elements rather
|
||||
* than assuming the header holds nothing else — real SMSCs put ports, language indicators and more
|
||||
* alongside it.
|
||||
*
|
||||
* `message` is a short_message whose first octet is the UDH length.
|
||||
*/
|
||||
export function concatInfo(message: Buffer): ConcatInfo | undefined {
|
||||
const udhLength = message[0];
|
||||
|
||||
if (udhLength === undefined) return undefined;
|
||||
|
||||
const end = Math.min(udhLength + 1, message.length);
|
||||
let offset = 1;
|
||||
|
||||
while (offset + 2 <= end) {
|
||||
const iei = message.readUInt8(offset);
|
||||
const ieLength = message.readUInt8(offset + 1);
|
||||
const data = message.subarray(offset + 2, offset + 2 + ieLength);
|
||||
|
||||
if (offset + 2 + ieLength > end) return undefined;
|
||||
|
||||
// 0x00 is an 8-bit concatenation reference, 0x08 a 16-bit one.
|
||||
if (iei === 0x00 && ieLength === 3) {
|
||||
return { part: data.readUInt8(2), reference: data.readUInt8(0), total: data.readUInt8(1) };
|
||||
}
|
||||
|
||||
if (iei === 0x08 && ieLength === 4) {
|
||||
return { part: data.readUInt8(3), reference: data.readUInt16BE(0), total: data.readUInt8(2) };
|
||||
}
|
||||
|
||||
offset += 2 + ieLength;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* A UUID version 7: 48 bits of Unix milliseconds followed by random bits, so ids sort by creation
|
||||
* time without carrying a MAC address the way version 1 does.
|
||||
*/
|
||||
export function uuidv7(): string {
|
||||
const bytes = randomBytes(16);
|
||||
|
||||
bytes.writeUIntBE(Date.now(), 0, 6);
|
||||
bytes.writeUInt8((bytes.readUInt8(6) & 0x0F) | 0x70, 6);
|
||||
bytes.writeUInt8((bytes.readUInt8(8) & 0x3F) | 0x80, 8);
|
||||
|
||||
const hex = bytes.toString('hex');
|
||||
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join('-');
|
||||
}
|
||||
Reference in New Issue
Block a user