- Added support for using the `net` module in the `send-sms` plugin to establish raw HTTP connections. - Refactored the `ztePost` function to `zteHttpPost`, allowing for custom HTTP request formatting and improved error handling. - Introduced a new utility function, `httpBodyFromRaw`, to process raw HTTP responses effectively. - Updated the `script-sandbox.js` to allow the use of the `net` and `node:net` modules in the sandbox environment.
219 lines
6.3 KiB
JavaScript
219 lines
6.3 KiB
JavaScript
import net from "node:net";
|
|
|
|
function passContext(ctx) {
|
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
|
return { ...ctx.context };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function requiredString(value, label) {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function pad2(n) {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
function formatSmsTime(date = new Date()) {
|
|
const offsetMinutes = -date.getTimezoneOffset();
|
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
const absHours = Math.abs(offsetMinutes) / 60;
|
|
const tz = Number.isInteger(absHours) ? String(absHours) : String(absHours);
|
|
return [
|
|
pad2(date.getFullYear() % 100),
|
|
pad2(date.getMonth() + 1),
|
|
pad2(date.getDate()),
|
|
pad2(date.getHours()),
|
|
pad2(date.getMinutes()),
|
|
pad2(date.getSeconds()),
|
|
`${sign}${tz}`,
|
|
].join(";");
|
|
}
|
|
|
|
function encodeUtf16BeHex(text) {
|
|
let out = "";
|
|
for (let i = 0; i < text.length; i++) {
|
|
out += text.charCodeAt(i).toString(16).toUpperCase().padStart(4, "0");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isGsm7(text) {
|
|
const basic =
|
|
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" +
|
|
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
|
|
const ext = new Set(["\f", "^", "{", "}", "\\", "[", "~", "]", "|", "€"]);
|
|
for (const ch of text) {
|
|
if (!basic.includes(ch) && !ext.has(ch)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function refererFromUrl(url) {
|
|
return `${new URL(url).origin}/index.html`;
|
|
}
|
|
|
|
function parseResult(text) {
|
|
const match = String(text).match(/\{[^{}]*"result"\s*:\s*"[^"]*"[^{}]*\}/);
|
|
if (!match) return { raw: String(text) };
|
|
try {
|
|
return JSON.parse(match[0]);
|
|
} catch {
|
|
return { raw: String(text) };
|
|
}
|
|
}
|
|
|
|
function httpBodyFromRaw(raw) {
|
|
const normalized = String(raw).replace(/\r\n/g, "\n");
|
|
const split = normalized.indexOf("\n\n");
|
|
return split === -1 ? normalized : normalized.slice(split + 2);
|
|
}
|
|
|
|
/**
|
|
* ZTE Demo-Webs uses LF-only status lines (`HTTP/1.1 200 OK\n`).
|
|
* Node 24 fetch/http throw HPE_CR_EXPECTED before any body is read.
|
|
*/
|
|
function zteHttpPost(url, fields) {
|
|
const target = new URL(url);
|
|
if (target.protocol !== "http:") {
|
|
throw new Error(`send-sms: only http: is supported (got ${target.protocol})`);
|
|
}
|
|
const body = new URLSearchParams(fields).toString();
|
|
const request = [
|
|
`POST ${target.pathname}${target.search} HTTP/1.1`,
|
|
`Host: ${target.host}`,
|
|
"accept: application/json, text/javascript, */*; q=0.01",
|
|
"accept-language: en-US,en;q=0.9",
|
|
"content-type: application/x-www-form-urlencoded; charset=UTF-8",
|
|
"x-requested-with: XMLHttpRequest",
|
|
`Referer: ${refererFromUrl(url)}`,
|
|
`Content-Length: ${Buffer.byteLength(body)}`,
|
|
"Connection: close",
|
|
"",
|
|
body,
|
|
].join("\r\n");
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.connect(
|
|
{ host: target.hostname, port: Number(target.port) || 80 },
|
|
() => {
|
|
socket.write(request);
|
|
},
|
|
);
|
|
const chunks = [];
|
|
let settled = false;
|
|
const finish = (raw) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.removeAllListeners();
|
|
socket.destroy();
|
|
resolve(parseResult(httpBodyFromRaw(raw)));
|
|
};
|
|
socket.on("data", (chunk) => {
|
|
chunks.push(chunk);
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
const parsed = parseResult(httpBodyFromRaw(raw));
|
|
if (parsed.result !== undefined) finish(raw);
|
|
});
|
|
socket.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
|
socket.on("error", (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(err);
|
|
});
|
|
socket.setTimeout(15000, () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
reject(new Error("ZTE HTTP timeout"));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function ztePost(url, fields) {
|
|
return zteHttpPost(url, fields);
|
|
}
|
|
|
|
async function sendZteSms(ctx) {
|
|
const url = requiredString(ctx.config?.url, "config.url");
|
|
const password = requiredString(ctx.config?.password, "config.password");
|
|
const to = requiredString(ctx.data?.to ?? ctx.data?.number, "data.to");
|
|
const message = requiredString(ctx.data?.message, "data.message");
|
|
|
|
log.info({ url }, "send-sms: logging in");
|
|
const login = await ztePost(url, {
|
|
goformId: "LOGIN",
|
|
password: btoa(password),
|
|
});
|
|
const loginResult = login.result;
|
|
if (loginResult !== "0" && loginResult !== 0 && loginResult !== "3" && loginResult !== 3 && loginResult !== "success") {
|
|
throw new Error(`ZTE login failed: ${JSON.stringify(login.result ?? login)}`);
|
|
}
|
|
|
|
const encodeType = isGsm7(message) ? "GSM7_default" : "UNICODE";
|
|
const smsTime = formatSmsTime();
|
|
log.info({ url, to, encodeType, messageLength: message.length }, "send-sms: sending");
|
|
const sent = await ztePost(url, {
|
|
goformId: "SEND_SMS",
|
|
notCallback: "true",
|
|
Number: to,
|
|
sms_time: smsTime,
|
|
MessageBody: encodeUtf16BeHex(message),
|
|
ID: "-1",
|
|
encode_type: encodeType,
|
|
});
|
|
if (sent.result !== "success" && sent.result !== "0" && sent.result !== 0) {
|
|
throw new Error(`ZTE SEND_SMS failed: ${JSON.stringify(sent.result ?? sent)}`);
|
|
}
|
|
|
|
log.info({ to, result: sent.result }, "send-sms: sent");
|
|
return {
|
|
output: { sent: true, to, result: sent.result },
|
|
context: passContext(ctx),
|
|
};
|
|
}
|
|
|
|
sendZteSms.meta = {
|
|
description: "Log in to a ZTE modem web UI and send an SMS",
|
|
previewConfigKey: "url",
|
|
tags: ["channel"],
|
|
config: {
|
|
url: {
|
|
type: "string",
|
|
required: true,
|
|
default: "http://192.168.5.1/reqproc/proc_post",
|
|
description: "Modem POST URL (LOGIN and SEND_SMS)",
|
|
},
|
|
password: {
|
|
type: "string",
|
|
required: true,
|
|
description: "Web UI password (sent as base64)",
|
|
},
|
|
},
|
|
input: {
|
|
to: { type: "string", required: true, description: "Recipient phone number" },
|
|
message: { type: "string", required: true, description: "SMS text" },
|
|
},
|
|
output: {
|
|
sent: { type: "boolean" },
|
|
to: { type: "string" },
|
|
result: { type: "string" },
|
|
},
|
|
example: {
|
|
data: {
|
|
to: "082134336193",
|
|
message: "Test nomor kirim aja dari XL prioritas",
|
|
},
|
|
config: {
|
|
url: "http://192.168.5.1/reqproc/proc_post",
|
|
password: "error403",
|
|
},
|
|
},
|
|
};
|
|
|
|
export default sendZteSms;
|