Files
lislgosms/tools/testing/http-cmpp-simulator.mjs
hectorzhao 92b112cc6e
CSS quality / css-quality (push) Has been cancelled
fix: honor Node all-address DNS lookup for HTTP webhooks
2026-09-14 14:03:00 +08:00

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));
},
};
}