// Real CMPP 2 TCP test peers. No real supplier forwarding exists in this module. import net from 'node:net'; import assert from 'node:assert/strict'; const z = (n) => Buffer.alloc(n); const fixed = (s, n) => { const b = z(n); b.write(s ?? '', 'ascii'); return b; }; const u32 = (n) => { const b = z(4); b.writeUInt32BE(n >>> 0); return b; }; const u64 = (n) => { const b = z(8); b.writeBigUInt64BE(BigInt(n)); return b; }; export function packet(type, sequence, body = z(0)) { return Buffer.concat([u32(body.length + 12), u32(type), u32(sequence), body]); } export function parseSubmit(body) { const count = body[116]; const end = 117 + 21 * count; assert(body.length >= end + 9); return { total: body[8], number: body[9], receipt: body[10], udhi: body[45], format: body[46], source: body.subarray(95, 116).toString('ascii').replace(/\0/g, ''), phones: Array.from({ length: count }, (_, i) => body .subarray(117 + i * 21, 138 + i * 21) .toString('ascii') .replace(/\0/g, ''), ), content: body.subarray(end + 1, end + 1 + body[end]), }; } export function decodeUcs2(buffer) { return Buffer.from(buffer).swap16().toString('utf16le'); } function framed(socket, onPacket, onError) { let accumulated = z(0); socket.on('error', onError); socket.on('data', (chunk) => { try { accumulated = Buffer.concat([accumulated, chunk]); while (accumulated.length >= 12) { const length = accumulated.readUInt32BE(0); assert(length >= 12 && length <= 65536, 'Invalid CMPP frame length'); if (accumulated.length < length) return; const value = accumulated.subarray(0, length); accumulated = accumulated.subarray(length); onPacket(value.readUInt32BE(4), value.readUInt32BE(8), value.subarray(12)); } } catch (error) { onError(error); socket.destroy(); } }); } export async function startSupplier({ host, port, accounts, phoneAllowed, events, receiptStatus = () => 'DELIVRD' }) { assert.equal(host, '127.0.0.1'); assert.equal(port, 17900); const sockets = new Set(); const connected = new Set(); const peers = new Map(); let id = BigInt(Date.now()) * 1000n; let sequence = 80000; const server = net.createServer((socket) => { if (socket.remoteAddress?.replace('::ffff:', '') !== '127.0.0.1') { socket.destroy(); return; } sockets.add(socket); let account; socket.on('close', () => { sockets.delete(socket); if (account) connected.delete(account); }); framed( socket, (type, seq, body) => { if (type === 1) { account = body.subarray(0, 6).toString('ascii').replace(/\0/g, ''); assert(accounts.has(account), 'Unexpected simulator account'); assert.equal(body[22], 0x20); socket.write(packet(0x80000001, seq, Buffer.concat([z(17), Buffer.from([0x20])]))); connected.add(account); peers.set(account, socket); events.push({ type: 'connect', account, time: Date.now() }); } else if (type === 8) socket.write(packet(0x80000008, seq, z(1))); else if (type === 2) { socket.write(packet(0x80000002, seq)); socket.end(); } else if (type === 4) { assert(account); const submit = parseSubmit(body); assert(submit.phones.every(phoneAllowed), 'Unexpected recipient: simulator stop condition'); assert(events.filter((x) => x.type === 'submit').length < 160, 'Wire submit cap reached'); const messageId = ++id; events.push({ type: 'submit', account, time: Date.now(), id: String(messageId), ...submit, content: submit.content.toString('base64'), }); socket.write(packet(0x80000004, seq, Buffer.concat([u64(messageId), z(1)]))); if (submit.receipt) { setTimeout(() => { if (socket.destroyed) return; const now = new Date(); const stamp = [ now.getFullYear() % 100, now.getMonth() + 1, now.getDate(), now.getHours(), now.getMinutes(), ] .map((n) => String(n).padStart(2, '0')) .join(''); const report = Buffer.concat([ u64(messageId), fixed(receiptStatus(submit), 7), fixed(stamp, 10), fixed(stamp, 10), fixed(submit.phones[0], 21), u32(sequence), ]); const deliver = Buffer.concat([ u64(messageId), fixed(submit.source, 21), z(10), z(3), fixed(submit.phones[0], 21), Buffer.from([1, report.length]), report, z(8), ]); socket.write(packet(5, ++sequence, deliver)); }, 150); } } else if (type === 0x80000005) events.push({ type: 'supplierReceiptAck', account, result: body[8], time: Date.now() }); }, (error) => { events.push({ type: 'error', message: error.message }); }, ); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); }); return { connected, sendUplink(account, phone, dest, text) { const socket = peers.get(account); assert(socket && !socket.destroyed); assert(phoneAllowed(phone)); const content = Buffer.from(text, 'utf16le').swap16(); assert(content.length <= 140); const messageId = ++id; const body = Buffer.concat([ u64(messageId), fixed(dest, 21), z(10), z(2), Buffer.from([8]), fixed(phone, 21), Buffer.from([0, content.length]), content, z(8), ]); socket.write(packet(5, ++sequence, body)); events.push({ type: 'supplierUplink', account, phone, dest, text, id: String(messageId), time: Date.now() }); }, close: async () => { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(resolve)); }, }; }