From bd0c11c20ce04b549924617ee65643837c4248a2 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 06:34:33 +0700 Subject: [PATCH] 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 --- .gitignore | 6 +- README.md | 3 +- packages/server/paths.js | 5 +- .../workflows/default/dev-joplin-daily.yaml | 29 +++ .../default/dev-joplin-get-note.yaml | 17 ++ .../workflows/default/dev-joplin-sync.yaml | 27 ++ .../server/workflows/default/dev-zte-sms.yaml | 16 ++ plugins/joplin-api/jerapah-plugin.json | 8 + plugins/joplin-api/package.json | 7 + plugins/joplin-api/script.js | 246 ++++++++++++++++++ plugins/send-sms/jerapah-plugin.json | 8 + plugins/send-sms/package.json | 7 + plugins/send-sms/script.js | 179 +++++++++++++ 13 files changed, 553 insertions(+), 5 deletions(-) create mode 100644 packages/server/workflows/default/dev-joplin-daily.yaml create mode 100644 packages/server/workflows/default/dev-joplin-get-note.yaml create mode 100644 packages/server/workflows/default/dev-joplin-sync.yaml create mode 100644 packages/server/workflows/default/dev-zte-sms.yaml create mode 100644 plugins/joplin-api/jerapah-plugin.json create mode 100644 plugins/joplin-api/package.json create mode 100644 plugins/joplin-api/script.js create mode 100644 plugins/send-sms/jerapah-plugin.json create mode 100644 plugins/send-sms/package.json create mode 100644 plugins/send-sms/script.js diff --git a/.gitignore b/.gitignore index 943a64c..8312f02 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ logs/ packages/web/dist # Personal/local scripts and workflows (not for the repo) -packages/server/scripts/dev-* -packages/server/workflows/**/dev-* debug-*.js + +# Plugin install staging and per-plugin deps +plugins/.staging-* +plugins/*/node_modules/ diff --git a/README.md b/README.md index d67ae25..d12a67d 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ The first account created becomes **admin**. Later accounts are created from Use | Kind | Name in YAML | Editable | Location | |---|---|---|---| | **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` | -| **Plugin** | `plugin/` | Yes | `data/plugins//` | +| **Plugin** | `plugin/` | Yes | `plugins//` | - 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/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`. - 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 # Smoke diff --git a/packages/server/paths.js b/packages/server/paths.js index 55043c2..f591164 100644 --- a/packages/server/paths.js +++ b/packages/server/paths.js @@ -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 WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows"); 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 = - 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. */ export const EXAMPLE_PLUGINS_DIR = path.resolve( SERVER_ROOT, diff --git a/packages/server/workflows/default/dev-joplin-daily.yaml b/packages/server/workflows/default/dev-joplin-daily.yaml new file mode 100644 index 0000000..4010125 --- /dev/null +++ b/packages/server/workflows/default/dev-joplin-daily.yaml @@ -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 diff --git a/packages/server/workflows/default/dev-joplin-get-note.yaml b/packages/server/workflows/default/dev-joplin-get-note.yaml new file mode 100644 index 0000000..a3d22f1 --- /dev/null +++ b/packages/server/workflows/default/dev-joplin-get-note.yaml @@ -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 diff --git a/packages/server/workflows/default/dev-joplin-sync.yaml b/packages/server/workflows/default/dev-joplin-sync.yaml new file mode 100644 index 0000000..f87b5f0 --- /dev/null +++ b/packages/server/workflows/default/dev-joplin-sync.yaml @@ -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 diff --git a/packages/server/workflows/default/dev-zte-sms.yaml b/packages/server/workflows/default/dev-zte-sms.yaml new file mode 100644 index 0000000..e351541 --- /dev/null +++ b/packages/server/workflows/default/dev-zte-sms.yaml @@ -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 diff --git a/plugins/joplin-api/jerapah-plugin.json b/plugins/joplin-api/jerapah-plugin.json new file mode 100644 index 0000000..c4071ff --- /dev/null +++ b/plugins/joplin-api/jerapah-plugin.json @@ -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)" +} diff --git a/plugins/joplin-api/package.json b/plugins/joplin-api/package.json new file mode 100644 index 0000000..3a0a91b --- /dev/null +++ b/plugins/joplin-api/package.json @@ -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)" +} diff --git a/plugins/joplin-api/script.js b/plugins/joplin-api/script.js new file mode 100644 index 0000000..fe08222 --- /dev/null +++ b/plugins/joplin-api/script.js @@ -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; diff --git a/plugins/send-sms/jerapah-plugin.json b/plugins/send-sms/jerapah-plugin.json new file mode 100644 index 0000000..a2fa7bd --- /dev/null +++ b/plugins/send-sms/jerapah-plugin.json @@ -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" +} diff --git a/plugins/send-sms/package.json b/plugins/send-sms/package.json new file mode 100644 index 0000000..3f925a8 --- /dev/null +++ b/plugins/send-sms/package.json @@ -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" +} diff --git a/plugins/send-sms/script.js b/plugins/send-sms/script.js new file mode 100644 index 0000000..42ce1ba --- /dev/null +++ b/plugins/send-sms/script.js @@ -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;