diff --git a/packages/server/scripts/ntfy.js b/packages/server/scripts/ntfy.js index 0584d2a..a41f63c 100644 --- a/packages/server/scripts/ntfy.js +++ b/packages/server/scripts/ntfy.js @@ -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", diff --git a/plugins/jakarta-prayer-time-parser-for-ntfy/jerapah-plugin.json b/plugins/jakarta-prayer-time-parser-for-ntfy/jerapah-plugin.json new file mode 100644 index 0000000..00d90fe --- /dev/null +++ b/plugins/jakarta-prayer-time-parser-for-ntfy/jerapah-plugin.json @@ -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)" +} diff --git a/plugins/jakarta-prayer-time-parser-for-ntfy/package.json b/plugins/jakarta-prayer-time-parser-for-ntfy/package.json new file mode 100644 index 0000000..ac8d5a2 --- /dev/null +++ b/plugins/jakarta-prayer-time-parser-for-ntfy/package.json @@ -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" +} diff --git a/plugins/jakarta-prayer-time-parser-for-ntfy/script.js b/plugins/jakarta-prayer-time-parser-for-ntfy/script.js new file mode 100644 index 0000000..9f87def --- /dev/null +++ b/plugins/jakarta-prayer-time-parser-for-ntfy/script.js @@ -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; diff --git a/plugins/jakarta-prayer-time-today-and-tomorrow/jerapah-plugin.json b/plugins/jakarta-prayer-time-today-and-tomorrow/jerapah-plugin.json new file mode 100644 index 0000000..203a147 --- /dev/null +++ b/plugins/jakarta-prayer-time-today-and-tomorrow/jerapah-plugin.json @@ -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)" +} diff --git a/plugins/jakarta-prayer-time-today-and-tomorrow/package.json b/plugins/jakarta-prayer-time-today-and-tomorrow/package.json new file mode 100644 index 0000000..9ccffe4 --- /dev/null +++ b/plugins/jakarta-prayer-time-today-and-tomorrow/package.json @@ -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" + } +} diff --git a/plugins/jakarta-prayer-time-today-and-tomorrow/pnpm-lock.yaml b/plugins/jakarta-prayer-time-today-and-tomorrow/pnpm-lock.yaml new file mode 100644 index 0000000..fb7f40b --- /dev/null +++ b/plugins/jakarta-prayer-time-today-and-tomorrow/pnpm-lock.yaml @@ -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: {} diff --git a/plugins/jakarta-prayer-time-today-and-tomorrow/script.js b/plugins/jakarta-prayer-time-today-and-tomorrow/script.js new file mode 100644 index 0000000..f6ea793 --- /dev/null +++ b/plugins/jakarta-prayer-time-today-and-tomorrow/script.js @@ -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;