// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) — // a free pub/sub push service: POST a message to a topic URL, anyone // subscribed to that topic (via the ntfy app or a browser) gets notified. // No API keys or push-subscription plumbing needed. import { lookup } from 'dns/promises'; import { isIP } from 'net'; import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js'; import { openPrayers } from './prayers.js'; import { logEvent } from './health.js'; import { fetchVerseOfDay, votdMessage } from './votd.js'; // A reminder still goes out if the server was briefly down (or restarting) at the // exact minute — as long as it's within this window after the chosen time. const GRACE_MINUTES = 180; const SEND_TIMEOUT_MS = 10_000; // A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours. const RETRY_AFTER_MS = 15 * 60 * 1000; const _lastAttempt = new Map(); // userId -> timestamp of the last failed send // True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6). export function isPrivateAddress(addr) { const v = isIP(addr); if (v === 4) { const [a, b] = addr.split('.').map(Number); return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; } if (v === 6) { const l = addr.toLowerCase(); if (l === '::' || l === '::1') return true; if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7)); return /^f[cd]/.test(l) || /^fe[89ab]/.test(l); } return true; } // Rejects server URLs that would make this server call itself or its private network. // A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1. export async function assertPublicUrl(raw) { if (process.env.ALLOW_PRIVATE_NTFY === '1') return; let u; try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); } if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.'); const host = u.hostname.replace(/^\[|\]$/g, ''); if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.'); const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address); if (addrs.length === 0) throw new Error(`Could not find ${host}.`); if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.'); } export function isValidTimeZone(tz) { if (!tz) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } } // The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the // server's own timezone when none (or an invalid one) is saved. export function zonedNow(date = new Date(), tz) { const timeZone = isValidTimeZone(tz) ? tz : undefined; const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).formatToParts(date).map((p) => [p.type, p.value]), ); return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) }; } // Is this user's reminder due right now (and not already sent today)? export function isDue(user, now = new Date()) { const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? ''); if (!match) return false; const target = Number(match[1]) * 60 + Number(match[2]); const local = zonedNow(now, user.reminderTz); if (user.lastReminderDate === local.date) return false; return local.minutes >= target && local.minutes < target + GRACE_MINUTES; } // POSTs one notification to the user's ntfy topic. Throws with a readable message on failure. export async function sendNtfy(user, { title, body, tags = 'open_book' }) { const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, ''); const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`; await assertPublicUrl(url); const headers = { Title: title, Tags: tags }; if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app let res; try { res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) }); } catch (err) { throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err }); } if (!res.ok) throw new Error(`ntfy responded ${res.status}`); } // Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)? export function readToday(progress, date) { return Array.isArray(progress?.activity) && progress.activity.includes(date); } export function reminderBody(progress) { const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length; return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!"; } /** Sends a one-off notification so the user can confirm their topic/server work. */ export function sendTestReminder(user) { return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' }); } export async function checkReminders(now = new Date()) { for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) { try { const progress = getReadingProgress(user.id); const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt); if (!hasActivePlan) continue; // nothing to nudge them about if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue; _lastAttempt.set(user.id, now.getTime()); await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) }); _lastAttempt.delete(user.id); setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date); } catch (err) { console.error(`Reading reminder failed for user ${user.id}:`, err.message); logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id }); } } } // Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all. export async function checkVotd(now = new Date()) { const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now)); if (due.length === 0) return; const verse = await fetchVerseOfDay(now); if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window) for (const user of due) { const key = `votd:${user.id}`; if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue; _lastAttempt.set(key, now.getTime()); try { await sendNtfy(user, votdMessage(verse)); _lastAttempt.delete(key); setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date); } catch (err) { console.error(`Verse of the day failed for user ${user.id}:`, err.message); logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id }); } } } // The message for the daily prayer reminder: how many requests are open, and the first few. export function prayerMessage(open) { const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`); const more = open.length - lines.length; return { title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`, body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`, tags: 'pray', }; } // Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when // the journal has no open requests. export async function checkPrayerReminders(now = new Date()) { for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) { const open = openPrayers(getPrayers(user.id)); if (open.length === 0) continue; const key = `prayer:${user.id}`; if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue; _lastAttempt.set(key, now.getTime()); try { await sendNtfy(user, prayerMessage(open)); _lastAttempt.delete(key); setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date); } catch (err) { console.error(`Prayer reminder failed for user ${user.id}:`, err.message); logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id }); } } } /** Checks once a minute whether any user's reminder time has arrived. */ export function startReminderScheduler() { setInterval(() => { checkReminders().catch((err) => console.error('Reminder scheduler error:', err)); checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err)); checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err)); }, 60 * 1000); }