Author SHA1 Message Date
nsrb 707e2c0f1e feat(plugins): enhance SMS sending functionality with raw HTTP handling
- 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.
2026-09-08 22:58:52 +07:00
nsrb 6c82ff20eb fix(plugins): enhance plugin installation and script handling
- Updated the `pnpm install` command in `plugin-install.js` to include the `--ignore-workspace` flag, ensuring proper installation of plugins within the app tree.
- Improved the `instantiateScriptSource` function in `script-sandbox.js` to resolve `pluginDir` more effectively, allowing for better package management.
- Added a new smoke test in `plugins-smoke.js` to validate the ability to require additional packages from plugin directories, enhancing testing coverage for plugin functionality.
- Updated documentation in `AGENTS.md` to reflect changes in the installation command.
2026-09-08 22:00:26 +07:00
nsrb 67ed3eecca feat(prayer-times): add Jakarta prayer time plugins and enhance ntfy integration
- Introduced two new plugins: `jakarta-prayer-time-parser-for-ntfy` for formatting prayer times as ntfy Markdown, and `jakarta-prayer-time-today-and-tomorrow` for fetching prayer times from Kemenag for today and tomorrow.
- Enhanced `ntfy.js` to support improved Markdown header handling and added a utility function for Markdown configuration validation.
- Updated plugin metadata and descriptions for clarity on functionality and usage.
- Implemented robust error handling and logging for better debugging and user feedback.
2026-08-30 21:25:09 +07:00
nsrb 80e9c91b88 refactor(scripts): remove admin role checks from plugin API endpoints
- Eliminated admin role checks from the create, install, and delete plugin API endpoints to simplify access control.
- Updated the ScriptsPage component to remove unnecessary user role checks, streamlining the installation process for all users.
- Improved UI by consolidating the zip installation button, enhancing user experience during plugin management.
2026-08-30 20:39:25 +07:00
nsrb 88d3692116 feat(scripts): add zip file installation functionality for plugins
- Introduced the ability to install plugins from zip files, enhancing the script management interface.
- Implemented file selection and base64 encoding for zip files before installation.
- Added confirmation dialog for installing plugins from zip, including warnings for existing plugins.
- Integrated user role checks to restrict zip installation to admin users only.
- Improved UI feedback during the installation process with loading indicators.
2026-08-30 20:27:26 +07:00
15 changed files with 726 additions and 40 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ Copy the layout from `plugins/joplin-api`, `plugins/send-sms`, or `examples/plug
Add `dependencies` only if the script `require()`s extra npm packages. Then:
```bash
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline --ignore-workspace
```
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
+10 -1
View File
@@ -51,9 +51,18 @@ export async function pnpmInstallPlugin(dir) {
}
if (!hasDeps) return { skipped: true };
// --ignore-workspace: plugins live under the app tree but are not workspace
// packages; without this, pnpm install --dir can no-op against the root monorepo.
await execFileAsync(
"pnpm",
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"],
[
"install",
"--dir",
dir,
"--ignore-scripts",
"--prefer-offline",
"--ignore-workspace",
],
{
cwd: dir,
env: { ...process.env, CI: "1" },
+10 -1
View File
@@ -22,7 +22,9 @@ const ALLOWED_MODULES = new Set([
"basic-ftp",
"jsonata",
"mustache",
"net",
"node-html-parser",
"node:net",
"node:stream",
"nodemailer",
"rss-parser",
@@ -557,16 +559,23 @@ function instantiateCompiled(
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null,
* }} [opts]
*
* When `pluginDir` is omitted, it is resolved from `script` so inspect/dry-run
* of `plugin/<id>` can `require()` extra packages from that plugin's node_modules.
*/
export function instantiateScriptSource(script, source, opts = {}) {
const compiled = compileScriptSource(source, script);
const pluginDir =
"pluginDir" in opts
? opts.pluginDir ?? null
: (resolveScriptRef(script).pluginDir ?? null);
const fn = instantiateCompiled(compiled, {
log: opts.log ?? inspectLog,
script,
workflowName: opts.workflowName ?? "inspect",
owner: opts.owner ?? DEFAULT_OWNER,
$workflows: opts.$workflows,
pluginDir: opts.pluginDir ?? null,
pluginDir,
});
return { fn, ...extractScriptMeta(fn) };
}
+15 -3
View File
@@ -15,13 +15,24 @@ function ntfyHeaders(ctx) {
headers.Title = ctx.data.title;
}
if (ctx.config?.markdown === true) {
headers.md = "true";
if (isMarkdownEnabled(ctx.config?.markdown)) {
// ntfy accepts X-Markdown / Markdown / md with true | 1 | yes
headers.Markdown = "yes";
log.info("ntfy: Markdown enabled");
}
return headers;
}
function isMarkdownEnabled(value) {
if (value === true || value === 1) return true;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
return v === "true" || v === "yes" || v === "1";
}
return false;
}
function resolveFingerprintKey(config) {
const fingerprint = config?.fingerprint;
if (fingerprint === true) return "fingerprint:ntfy";
@@ -156,7 +167,8 @@ ntfy.meta = {
markdown: {
type: "boolean",
default: false,
description: "Send as Markdown (ntfy md header)",
description:
"Render body as Markdown (sets Markdown: yes; aliases X-Markdown / md also accepted by ntfy)",
},
fingerprint: {
type: "string",
-3
View File
@@ -466,7 +466,6 @@ export default function scriptsPluginFactory(registry) {
fastify.post(
"/plugins/create",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
req.body ?? {}
@@ -498,7 +497,6 @@ export default function scriptsPluginFactory(registry) {
fastify.post(
"/plugins/install",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{
source?: string,
@@ -573,7 +571,6 @@ export default function scriptsPluginFactory(registry) {
fastify.delete(
"/plugins/:id",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
+49
View File
@@ -9,6 +9,7 @@
*/
import assert from "node:assert/strict";
import fs from "fs";
import path from "node:path";
import { migrate, db } from "../db.js";
import { getAppVersion, satisfiesRange } from "../app-version.js";
import {
@@ -18,6 +19,7 @@ import {
uninstallPlugin,
listInstalledPlugins,
createBlankPlugin,
pluginDir,
} from "../plugin-store.js";
import { installExamplePlugin } from "../plugin-install.js";
import {
@@ -119,11 +121,58 @@ async function main() {
);
assert.ok(meta);
const extra = createBlankPlugin(
"extra-require-smoke",
`const extra = require("smoke-extra");
async function main() {
return { output: { n: extra.n } };
}
main.meta = {
description: "smoke extra require",
config: {},
input: {},
output: { n: { type: "number" } },
example: { data: {}, config: {} },
};
export default main;
`,
);
assert.equal(extra.scriptRef, "plugin/extra-require-smoke");
const extraPkg = path.join(
pluginDir("extra-require-smoke"),
"node_modules",
"smoke-extra",
);
fs.mkdirSync(extraPkg, { recursive: true });
fs.writeFileSync(
path.join(extraPkg, "package.json"),
`${JSON.stringify({ name: "smoke-extra", main: "index.js" })}\n`,
);
fs.writeFileSync(path.join(extraPkg, "index.js"), "module.exports = { n: 9 };\n");
clearScriptCache();
const extraSource = fs.readFileSync(
path.join(pluginDir("extra-require-smoke"), "script.js"),
"utf8",
);
const extraInspect = inspectScriptSource(
"plugin/extra-require-smoke",
extraSource,
);
assert.equal(extraInspect.metaError, null, extraInspect.metaError);
assert.equal(extraInspect.meta?.description, "smoke extra require");
const extraRun = await runScript(
"plugin/extra-require-smoke",
{ data: null, context: {}, config: null },
{ log: silent, workflowName: "smoke", owner: "default" },
);
assert.equal(extraRun.output.n, 9);
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
uninstallPlugin("jsonata-smoke-fork");
uninstallPlugin("blank-smoke");
uninstallPlugin("blank-smoke-copy");
uninstallPlugin("extra-require-smoke");
uninstallPlugin("get-current-time");
console.log("plugins-smoke: ok");
+99 -3
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2, LuUpload } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteScript,
@@ -15,6 +15,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
import { scriptTags } from "../lib/script.js";
import { useNotifications } from "../notifications.jsx";
const BASE64_CHUNK = 0x8000;
function uint8ArrayToBase64(bytes) {
let binary = "";
for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
}
return btoa(binary);
}
async function fileToBase64(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
return uint8ArrayToBase64(bytes);
}
export function ScriptsPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
@@ -31,6 +46,10 @@ export function ScriptsPage() {
const duplicate = useDuplicatePlugin();
const install = useInstallPlugin();
const { notify } = useNotifications();
const [pendingZip, setPendingZip] = useState(null);
const [zipEncoding, setZipEncoding] = useState(false);
const zipInFlight = useRef(false);
const zipBusy = zipEncoding || install.isPending;
const visible = useMemo(() => {
const term = query.trim().toLowerCase();
@@ -49,6 +68,44 @@ export function ScriptsPage() {
});
}, [scripts, query]);
function onPickZip(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
install.reset();
setPendingZip(file);
}
async function installPendingZip() {
if (!pendingZip || zipInFlight.current) return;
zipInFlight.current = true;
setZipEncoding(true);
try {
const zipBase64 = await fileToBase64(pendingZip);
install.mutate(
{ source: "zip", zipBase64, overwrite: true },
{
onSuccess: (data) => {
setPendingZip(null);
const ref =
data.scriptRef ?? (data.id ? `plugin/${data.id}` : "plugin");
notify.success(`Installed ${ref} — drain-restart to load workers`);
if (data.warning) notify.warning(data.warning);
},
onError: (err) => notify.error(errorMessage(err)),
onSettled: () => {
zipInFlight.current = false;
setZipEncoding(false);
},
},
);
} catch (err) {
zipInFlight.current = false;
setZipEncoding(false);
notify.error(errorMessage(err));
}
}
if (editName) {
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
}
@@ -71,7 +128,7 @@ export function ScriptsPage() {
<button
type="button"
className="btn btn-outline btn-sm"
disabled={install.isPending}
disabled={zipBusy}
onClick={() =>
install.mutate(
{ source: "example", exampleId: "get-current-time", overwrite: true },
@@ -84,6 +141,21 @@ export function ScriptsPage() {
>
Install example
</button>
<label className={`btn btn-outline btn-sm ${zipBusy ? "btn-disabled" : ""}`}>
{zipBusy ? (
<span className="loading loading-spinner loading-xs" />
) : (
<LuUpload className="size-4" />
)}
Install from zip
<input
type="file"
accept=".zip,application/zip"
className="hidden"
disabled={zipBusy}
onChange={onPickZip}
/>
</label>
<Link to="/scripts/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" />
Add plugin
@@ -341,6 +413,30 @@ export function ScriptsPage() {
</dialog>
) : null}
<ConfirmDialog
open={Boolean(pendingZip)}
title="Install plugin from zip?"
message={
pendingZip ? (
<>
Installing{" "}
<span className="font-mono">{pendingZip.name}</span> replaces an
existing plugin with the same id. Drain-restart workers after
install so new dependencies load.
</>
) : null
}
confirmLabel="Install"
confirmClass="btn-primary"
error={install.isError ? errorMessage(install.error) : null}
loading={zipBusy}
onCancel={() => {
if (zipBusy) return;
setPendingZip(null);
}}
onConfirm={installPendingZip}
/>
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
@@ -0,0 +1,8 @@
{
"id": "jakarta-prayer-time-parser-for-ntfy",
"name": "Jakarta prayer times → ntfy",
"version": "0.2.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Format today/tomorrow prayer times as ntfy Markdown (headings + tables)"
}
@@ -0,0 +1,7 @@
{
"name": "jflow-plugin-jakarta-prayer-time-parser-for-ntfy",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "JerapahFlow plugin: format Jakarta prayer times as ntfy Markdown"
}
@@ -0,0 +1,165 @@
const PRAYERS = [
["imsak", "Imsak"],
["subuh", "Subuh"],
["terbit", "Terbit"],
["dhuha", "Dhuha"],
["dzuhur", "Dzuhur"],
["ashar", "Ashar"],
["maghrib", "Maghrib"],
["isya", "Isya"],
];
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.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function requireDay(day, label) {
if (day == null || typeof day !== "object" || Array.isArray(day)) {
throw new Error(
`parser: missing ${label} prayer-time data (run plugin/jakarta-prayer-time-today-and-tomorrow first)`,
);
}
return day;
}
function dayCaption(day, fallbackDate) {
const tanggal = unwrapScalar(day.tanggal);
if (tanggal) return tanggal;
return unwrapScalar(fallbackDate);
}
function formatDayTable(heading, day) {
const lines = [
`### ${heading}`,
"",
"| Prayer | Time |",
"| --- | --- |",
];
for (const [key, label] of PRAYERS) {
const time = unwrapScalar(day[key]) || "—";
lines.push(`| ${label} | ${time} |`);
}
return lines.join("\n");
}
async function parsePrayerTimesForNtfy(ctx) {
const data = ctx?.data && typeof ctx.data === "object" ? ctx.data : {};
const today = requireDay(data.today, "today");
const tomorrow = requireDay(data.tomorrow, "tomorrow");
const location = unwrapScalar(ctx.config?.location) || "Jakarta";
const todayCaption = dayCaption(today, data.todayDate);
const tomorrowCaption = dayCaption(tomorrow, data.tomorrowDate);
const todayHeading = todayCaption ? `Today — ${todayCaption}` : "Today";
const tomorrowHeading = tomorrowCaption
? `Tomorrow — ${tomorrowCaption}`
: "Tomorrow";
const message = [
formatDayTable(todayHeading, today),
"",
"---",
"",
formatDayTable(tomorrowHeading, tomorrow),
].join("\n");
const title = todayCaption
? `Prayer Times (${location}): ${todayCaption}`
: `Prayer Times (${location})`;
log.info(
{ location, todayCaption, tomorrowCaption },
"jakarta-prayer-time-parser-for-ntfy: formatted",
);
const output = { ok: true, title, message };
return { output, context: { ...passContext(ctx), ...output } };
}
parsePrayerTimesForNtfy.meta = {
description:
"Format today/tomorrow prayer times as ntfy Markdown (headings + tables). Set ntfy.js config.markdown to true.",
previewConfigKey: "location",
tags: ["ntfy", "Markdown", "Prayer Times"],
config: {
location: {
type: "string",
default: "Jakarta",
description: "City name used in the ntfy title",
},
},
input: {
today: {
type: "any",
required: true,
description: "Today's prayer-time record from the fetcher",
},
tomorrow: {
type: "any",
required: true,
description: "Tomorrow's prayer-time record from the fetcher",
},
todayDate: {
type: "string",
required: false,
description: "YYYY-MM-DD fallback when today.tanggal is missing",
},
tomorrowDate: {
type: "string",
required: false,
description: "YYYY-MM-DD fallback when tomorrow.tanggal is missing",
},
},
output: {
ok: { type: "boolean" },
title: { type: "string", description: "ntfy Title header" },
message: { type: "string", description: "Markdown body (headings + tables)" },
},
context: {
ok: { type: "boolean" },
title: { type: "string" },
message: { type: "string" },
},
example: {
data: {
todayDate: "2026-08-30",
tomorrowDate: "2026-08-31",
today: {
tanggal: "Minggu, 30/08/2026",
imsak: "04:32",
subuh: "04:42",
terbit: "05:57",
dhuha: "06:26",
dzuhur: "11:58",
ashar: "15:18",
maghrib: "17:58",
isya: "19:08",
},
tomorrow: {
tanggal: "Senin, 31/08/2026",
imsak: "04:32",
subuh: "04:42",
terbit: "05:57",
dhuha: "06:26",
dzuhur: "11:58",
ashar: "15:17",
maghrib: "17:58",
isya: "19:08",
},
},
config: { location: "Jakarta" },
},
};
export default parsePrayerTimesForNtfy;
@@ -0,0 +1,8 @@
{
"id": "jakarta-prayer-time-today-and-tomorrow",
"name": "Jakarta prayer times (today & tomorrow)",
"version": "0.2.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Fetch Kemenag prayer times for today and tomorrow using dayjs (Asia/Jakarta)"
}
@@ -0,0 +1,10 @@
{
"name": "jflow-plugin-jakarta-prayer-time-today-and-tomorrow",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "JerapahFlow plugin: fetch Jakarta prayer times for today and tomorrow",
"dependencies": {
"dayjs": "^1.11.13"
}
}
@@ -0,0 +1,22 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
dayjs:
specifier: ^1.11.13
version: 1.11.21
packages:
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
snapshots:
dayjs@1.11.21: {}
@@ -0,0 +1,255 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);
const PAGE_URL = "https://bimasislam.kemenag.go.id/jadwalshalat";
const API_URL = "https://bimasislam.kemenag.go.id/ajax/getShalatbln";
/** Default Kemenag hashes: DKI Jakarta / Kota Jakarta. */
const DEFAULT_X = "c51ce410c124a10e0db5e4b97fc2af39";
const DEFAULT_Y = "58a2fc6ed39fd083f55d4182bf88826d";
const DEFAULT_TIMEZONE = "Asia/Jakarta";
const BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "en-US,en;q=0.9,id;q=0.8",
"cache-control": "no-cache",
pragma: "no-cache",
};
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.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function monthKey(d) {
return `${d.year()}-${d.month() + 1}`;
}
function parseCookies(setCookie) {
if (!setCookie) return "";
return setCookie
.split(/,(?=[^;,]+=)/)
.map((cookie) => cookie.split(";")[0])
.join("; ");
}
async function getSessionCookie() {
const cookieFetch = await fetch(PAGE_URL, {
method: "GET",
headers: {
...BROWSER_HEADERS,
"upgrade-insecure-requests": "1",
},
});
const cookies = parseCookies(cookieFetch.headers.get("set-cookie"));
if (!cookies) {
throw new Error(
`Prayer-time session cookie missing (HTTP ${cookieFetch.status})`,
);
}
return cookies;
}
async function fetchPrayerMonth(cookies, month, year, x, y) {
const resp = await fetch(API_URL, {
method: "POST",
headers: {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": BROWSER_HEADERS["accept-language"],
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
pragma: "no-cache",
"x-requested-with": "XMLHttpRequest",
cookie: cookies,
Referer: PAGE_URL,
},
body: new URLSearchParams({
x,
y,
bln: String(month),
thn: String(year),
}).toString(),
});
const responseText = await resp.text();
let responseData;
try {
responseData = JSON.parse(responseText);
} catch {
throw new Error(
`Prayer-time API returned invalid JSON (HTTP ${resp.status}) for ${year}-${month}`,
);
}
if (!resp.ok) {
throw new Error(
`Prayer-time API request failed with HTTP ${resp.status} for ${year}-${month}`,
);
}
return {
data: responseData,
status: resp.status,
month: Number(month),
year: Number(year),
};
}
/**
* Resolve one day's record from a monthly API payload.
* Accepts `{ data: { "YYYY-MM-DD": {...} } }` or a date-keyed object.
*/
function getDateData(monthResponse, dateKey) {
const candidates = [
monthResponse?.data?.data,
monthResponse?.data,
monthResponse,
];
for (const map of candidates) {
if (map && typeof map === "object" && !Array.isArray(map) && map[dateKey]) {
const day = map[dateKey];
if (typeof day === "object" && !Array.isArray(day)) {
return day;
}
}
}
throw new Error(`No prayer times for ${dateKey}`);
}
async function fetchPrayerTimes(ctx) {
const timezoneName = unwrapScalar(ctx.config?.timezone) || DEFAULT_TIMEZONE;
const x = unwrapScalar(ctx.config?.x) || DEFAULT_X;
const y = unwrapScalar(ctx.config?.y) || DEFAULT_Y;
const today = dayjs().tz(timezoneName);
if (!today.isValid()) {
throw new Error(`invalid timezone "${timezoneName}"`);
}
const tomorrow = today.add(1, "day");
const todayDate = today.format("YYYY-MM-DD");
const tomorrowDate = tomorrow.format("YYYY-MM-DD");
log.info(
{ today: todayDate, tomorrow: tomorrowDate, timezone: timezoneName },
"jakarta-prayer-time-today-and-tomorrow: fetching",
);
const cookies = await getSessionCookie();
const needed = [
{ key: monthKey(today), month: today.month() + 1, year: today.year() },
{ key: monthKey(tomorrow), month: tomorrow.month() + 1, year: tomorrow.year() },
];
const unique = [];
const seen = new Set();
for (const item of needed) {
if (seen.has(item.key)) continue;
seen.add(item.key);
unique.push(item);
}
const monthResponses = {};
await Promise.all(
unique.map(async (item) => {
monthResponses[item.key] = await fetchPrayerMonth(
cookies,
item.month,
item.year,
x,
y,
);
}),
);
const todayData = getDateData(monthResponses[monthKey(today)], todayDate);
const tomorrowData = getDateData(
monthResponses[monthKey(tomorrow)],
tomorrowDate,
);
const output = {
ok: true,
today: todayData,
tomorrow: tomorrowData,
todayDate,
tomorrowDate,
timezone: timezoneName,
};
log.info(
{
todayFound: Boolean(output.today),
tomorrowFound: Boolean(output.tomorrow),
monthsFetched: unique.length,
},
"jakarta-prayer-time-today-and-tomorrow: extracted",
);
return {
output,
context: { ...passContext(ctx), ...output },
};
}
fetchPrayerTimes.meta = {
description:
"Fetch Kemenag Bimas Islam prayer times for today and tomorrow (Asia/Jakarta by default).",
previewConfigKey: "timezone",
tags: ["HTTP", "Prayer Times"],
config: {
timezone: {
type: "string",
default: DEFAULT_TIMEZONE,
description: "IANA timezone used to pick today/tomorrow",
},
x: {
type: "string",
default: DEFAULT_X,
description: "Kemenag kabupaten hash (x). Default is DKI Jakarta",
},
y: {
type: "string",
default: DEFAULT_Y,
description: "Kemenag kota hash (y). Default is Kota Jakarta",
},
},
input: {},
output: {
ok: { type: "boolean" },
today: { type: "any", description: "Prayer-time record for today" },
tomorrow: { type: "any", description: "Prayer-time record for tomorrow" },
todayDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
tomorrowDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
timezone: { type: "string" },
},
context: {
ok: { type: "boolean" },
today: { type: "any" },
tomorrow: { type: "any" },
todayDate: { type: "string" },
tomorrowDate: { type: "string" },
timezone: { type: "string" },
},
example: {
data: {},
config: {
timezone: "Asia/Jakarta",
},
},
};
export default fetchPrayerTimes;
+67 -28
View File
@@ -1,3 +1,5 @@
import net from "node:net";
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
@@ -65,38 +67,75 @@ function parseResult(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;
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);
}
return null;
/**
* 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) {
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;
}
return zteHttpPost(url, fields);
}
async function sendZteSms(ctx) {