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;