feat(plugins): add joplin-api and send-sms under repo-root plugins/
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>
This commit is contained in:
+4
-2
@@ -7,6 +7,8 @@ logs/
|
|||||||
packages/web/dist
|
packages/web/dist
|
||||||
|
|
||||||
# Personal/local scripts and workflows (not for the repo)
|
# Personal/local scripts and workflows (not for the repo)
|
||||||
packages/server/scripts/dev-*
|
|
||||||
packages/server/workflows/**/dev-*
|
|
||||||
debug-*.js
|
debug-*.js
|
||||||
|
|
||||||
|
# Plugin install staging and per-plugin deps
|
||||||
|
plugins/.staging-*
|
||||||
|
plugins/*/node_modules/
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ The first account created becomes **admin**. Later accounts are created from Use
|
|||||||
| Kind | Name in YAML | Editable | Location |
|
| Kind | Name in YAML | Editable | Location |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||||
| **Plugin** | `plugin/<id>` | Yes | `data/plugins/<id>/` |
|
| **Plugin** | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||||
|
|
||||||
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
||||||
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
||||||
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
||||||
|
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke
|
# Smoke
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||||
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||||
/** Installed user plugins (outside the pnpm workspace). */
|
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||||
export const PLUGINS_DIR =
|
export const PLUGINS_DIR =
|
||||||
process.env.JFLOW_PLUGINS_DIR ?? path.join(DATA_DIR, "plugins");
|
process.env.JFLOW_PLUGINS_DIR ??
|
||||||
|
path.resolve(SERVER_ROOT, "../../plugins");
|
||||||
/** Example plugin sources shipped with the repo. */
|
/** Example plugin sources shipped with the repo. */
|
||||||
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
||||||
SERVER_ROOT,
|
SERVER_ROOT,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Joplin nightly daily log
|
||||||
|
description: |
|
||||||
|
POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy.
|
||||||
|
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3040/api/logs/run
|
||||||
|
method: POST
|
||||||
|
token: $SECRET_joplin_setup_token
|
||||||
|
timeoutMs: 600000
|
||||||
|
- set:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"title": data.ok ? "Bullet journal" : "Bullet journal failed",
|
||||||
|
"message": data.ok
|
||||||
|
? "The bullet journal for " & data.httpResponse.date & " has been created."
|
||||||
|
: data.message
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: $VAR_ntfy_channel
|
||||||
|
triggers:
|
||||||
|
- type: cron
|
||||||
|
schedule: "0 4 * * *"
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-daily
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Joplin get note
|
||||||
|
description: |
|
||||||
|
GET a Joplin note by id from joplin-api. Input (data): id (32-char hex).
|
||||||
|
Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3030/notes
|
||||||
|
method: GET
|
||||||
|
token: $SECRET_joplin_api_token
|
||||||
|
timeoutMs: 60000
|
||||||
|
triggers:
|
||||||
|
- type: workflow
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-get-note
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Joplin nightly sync
|
||||||
|
description: |
|
||||||
|
POST joplin-auto /api/sync at 03:00, then ntfy success or failure.
|
||||||
|
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3040/api/sync
|
||||||
|
method: POST
|
||||||
|
token: $SECRET_joplin_setup_token
|
||||||
|
timeoutMs: 600000
|
||||||
|
- set:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"title": data.ok ? "Joplin sync ok" : "Joplin sync failed",
|
||||||
|
"message": data.message
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: $VAR_ntfy_channel
|
||||||
|
triggers:
|
||||||
|
- type: cron
|
||||||
|
schedule: "0 3 * * *"
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-sync
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: dev-zte-sms
|
||||||
|
description: |
|
||||||
|
Send an SMS via a ZTE modem web UI.
|
||||||
|
Input (data): to, message
|
||||||
|
Password is the named secret zte_modem_password ($SECRET_).
|
||||||
|
scripts:
|
||||||
|
- script: plugin/send-sms
|
||||||
|
config:
|
||||||
|
url: http://192.168.5.1/reqproc/proc_post
|
||||||
|
password: $SECRET_sms_secret
|
||||||
|
triggers:
|
||||||
|
- type: workflow
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-zte-sms
|
||||||
|
auth: basic-auth
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "joplin-api",
|
||||||
|
"name": "Joplin API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "GET/POST a Joplin host with Bearer token using native fetch (LAN/WG IPs)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-joplin-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: Joplin HTTP with Bearer token (native fetch)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||||
|
const JOPLIN_ID_RE = /^[a-fA-F0-9]{32}$/;
|
||||||
|
|
||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapScalar(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (typeof value.reveal === "function") {
|
||||||
|
return unwrapScalar(value.reveal());
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(ctx, startedAt, message, extra = {}) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const output = {
|
||||||
|
ok: false,
|
||||||
|
status: extra.status ?? null,
|
||||||
|
httpResponse: extra.httpResponse ?? null,
|
||||||
|
durationMs,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
log.warn({ durationMs, status: output.status, message }, "joplin-api: failed");
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function okResult(ctx, startedAt, status, httpResponse, message) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const output = {
|
||||||
|
ok: true,
|
||||||
|
status,
|
||||||
|
httpResponse,
|
||||||
|
durationMs,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
log.info({ durationMs, status }, "joplin-api: ok");
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMethod(ctx) {
|
||||||
|
const raw = unwrapScalar(ctx.config?.method || ctx.data?.method || "POST");
|
||||||
|
const method = raw.toUpperCase();
|
||||||
|
if (!HTTP_METHODS.includes(method)) {
|
||||||
|
return { error: `unsupported method "${raw}"` };
|
||||||
|
}
|
||||||
|
return { method };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutMs(ctx) {
|
||||||
|
const raw = ctx.config?.timeoutMs ?? ctx.data?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteIdFromData(ctx) {
|
||||||
|
const id = unwrapScalar(ctx.data?.id).trim();
|
||||||
|
return id.length > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(base, id) {
|
||||||
|
const trimmed = base.replace(/\/+$/, "");
|
||||||
|
return `${trimmed}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizationHeader(token) {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (/^bearer\s+/i.test(trimmed)) return trimmed;
|
||||||
|
return `Bearer ${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSuccess(status, body, durationMs) {
|
||||||
|
if (body != null && typeof body === "object" && !Array.isArray(body)) {
|
||||||
|
if (typeof body.date === "string" && body.date) {
|
||||||
|
return `ok ${status} date=${body.date} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
if (typeof body.status === "string" && body.status) {
|
||||||
|
return `ok ${status} ${body.status} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `ok ${status} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeError(status, body) {
|
||||||
|
if (body != null && typeof body === "object" && typeof body.error === "string") {
|
||||||
|
return status != null ? `HTTP ${status}: ${body.error}` : body.error;
|
||||||
|
}
|
||||||
|
if (typeof body === "string" && body.length > 0) {
|
||||||
|
const clip = body.length > 300 ? `${body.slice(0, 300)}…` : body;
|
||||||
|
return status != null ? `HTTP ${status}: ${clip}` : clip;
|
||||||
|
}
|
||||||
|
return status != null ? `HTTP ${status}` : "request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseBody(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length === 0) return null;
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json") || /^[\[{]/.test(text.trim())) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joplinHttp(ctx) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const url = unwrapScalar(ctx.config?.url).trim();
|
||||||
|
if (!url) {
|
||||||
|
return fail(ctx, startedAt, "config.url is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = unwrapScalar(ctx.config?.token);
|
||||||
|
if (!token) {
|
||||||
|
return fail(ctx, startedAt, "config.token is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodResult = resolveMethod(ctx);
|
||||||
|
if (methodResult.error) {
|
||||||
|
return fail(ctx, startedAt, methodResult.error);
|
||||||
|
}
|
||||||
|
const { method } = methodResult;
|
||||||
|
|
||||||
|
const id = noteIdFromData(ctx);
|
||||||
|
if (id != null && !JOPLIN_ID_RE.test(id)) {
|
||||||
|
return fail(ctx, startedAt, "data.id must be a 32-character hex Joplin id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = id != null ? joinUrl(url, id) : url;
|
||||||
|
const timeoutMs = resolveTimeoutMs(ctx);
|
||||||
|
|
||||||
|
log.info({ url: requestUrl, method, timeoutMs, hasId: id != null }, "joplin-api: request");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: authorizationHeader(token),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const httpResponse = await parseBody(response);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (response.ok) {
|
||||||
|
return okResult(
|
||||||
|
ctx,
|
||||||
|
startedAt,
|
||||||
|
response.status,
|
||||||
|
httpResponse,
|
||||||
|
summarizeSuccess(response.status, httpResponse, durationMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fail(ctx, startedAt, summarizeError(response.status, httpResponse), {
|
||||||
|
status: response.status,
|
||||||
|
httpResponse,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const name = err instanceof Error ? err.name : "";
|
||||||
|
const message =
|
||||||
|
name === "TimeoutError" || name === "AbortError"
|
||||||
|
? `request timed out after ${timeoutMs}ms`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
return fail(ctx, startedAt, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joplinHttp.meta = {
|
||||||
|
description:
|
||||||
|
"GET/POST a Joplin host with Bearer token using native fetch (LAN/WG IPs). Optional data.id is appended to the URL. Does not throw; ok=false on failure.",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP", "Joplin"],
|
||||||
|
config: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Request URL. data.id is appended as a path segment when set.",
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
type: "string",
|
||||||
|
default: "POST",
|
||||||
|
enum: HTTP_METHODS,
|
||||||
|
description: "HTTP method",
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Bearer token ($SECRET_); Bearer prefix is added if missing",
|
||||||
|
},
|
||||||
|
timeoutMs: {
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
description: "Abort timeout in milliseconds (default 600000)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "32-char hex Joplin note id; appended to config.url",
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Fallback when config.method is omitted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
status: { type: "number", description: "HTTP status, or null on network error" },
|
||||||
|
httpResponse: { type: "any", description: "Parsed JSON or response text" },
|
||||||
|
durationMs: { type: "number" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
status: { type: "number" },
|
||||||
|
httpResponse: { type: "any" },
|
||||||
|
durationMs: { type: "number" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||||
|
config: {
|
||||||
|
url: "http://10.8.0.6:3030/notes",
|
||||||
|
method: "GET",
|
||||||
|
token: "$SECRET_joplin_api_token",
|
||||||
|
timeoutMs: 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default joplinHttp;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "send-sms",
|
||||||
|
"name": "Send SMS",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "Log in to a ZTE modem web UI and send an SMS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-send-sms",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: send SMS via a ZTE modem web UI"
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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;
|
||||||
Reference in New Issue
Block a user