Load user plugins from /plugins instead of data/plugins so site-specific scripts stay in git and workflows can reference plugin/joplin-api and plugin/send-sms. Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
5.1 KiB
JavaScript
180 lines
5.1 KiB
JavaScript
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 leftoverFromError(err) {
|
|
let current = err;
|
|
for (let i = 0; i < 6 && current; i++) {
|
|
if (typeof current.data === "string") return current.data;
|
|
current = current.cause;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function ztePost(url, fields) {
|
|
const body = new URLSearchParams(fields).toString();
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "application/json, text/javascript, */*; q=0.01",
|
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
"x-requested-with": "XMLHttpRequest",
|
|
Referer: refererFromUrl(url),
|
|
},
|
|
body,
|
|
});
|
|
return parseResult(await response.text());
|
|
} catch (err) {
|
|
const leftover = leftoverFromError(err);
|
|
if (leftover != null) {
|
|
const parsed = parseResult(leftover);
|
|
log.info({ result: parsed.result ?? parsed }, "send-sms: recovered malformed response");
|
|
return parsed;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
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;
|