191 lines
6.2 KiB
JavaScript
191 lines
6.2 KiB
JavaScript
// 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));
|
|
},
|
|
};
|
|
}
|