import type { AlertKind } from "./email"; import type { Check, Region } from "./monitor"; const API = "https://api.pushover.net/1/messages.json"; /** Pushover's limits: title 250 characters, message 1024. */ const MAX_TITLE = 250; const MAX_MESSAGE = 1024; /** Optional secrets; the channel is off until both are set. */ type PushoverEnv = Env & { PUSHOVER_TOKEN?: string; PUSHOVER_USER?: string }; export function pushoverEnabled(env: Env): boolean { const e = env as PushoverEnv; return Boolean(e.PUSHOVER_TOKEN && e.PUSHOVER_USER); } /** * Sends an alert as a Pushover notification, the second channel next to email. Returns false (and * logs) on failure rather than throwing. Call only when pushoverEnabled(). */ export async function sendPushover( env: Env, kind: AlertKind, check: Check, failingSince: number | null, failingRegions: Region[] = [], ): Promise { const e = env as PushoverEnv; const host = check.endpoint.replace(/^https?:\/\//, ""); const result = check.status ? `HTTP ${check.status}` : (check.error ?? "no response"); const title = `${kind.toUpperCase()}: ${host} (${result}) from ${check.region}`; const lines: string[] = []; if (failingSince !== null) lines.push(`Failing since ${new Date(failingSince).toISOString()}`); if (failingRegions.length > 1) lines.push(`Failing from ${failingRegions.join(", ")}`); if (kind !== "recovered") lines.push(...failureLines(check)); const form = new URLSearchParams({ token: e.PUSHOVER_TOKEN!, user: e.PUSHOVER_USER!, title: title.slice(0, MAX_TITLE), message: (lines.join("\n") || result).slice(0, MAX_MESSAGE), // Recoveries arrive quietly; failures make a sound. priority: kind === "recovered" ? "-1" : "0", url: `${env.PUBLIC_URL}/endpoint?url=${encodeURIComponent(check.endpoint)}`, url_title: "History", timestamp: String(Math.floor(check.ts / 1000)), }); try { const res = await fetch(API, { method: "POST", body: form, signal: AbortSignal.timeout(Number(env.TIMEOUT_MS)) }); const reply = await res.text(); if (!res.ok || !reply.includes('"status":1')) throw new Error(`HTTP ${res.status}: ${reply.slice(0, 200)}`); console.log("alert pushover sent", { endpoint: check.endpoint, kind, title }); return true; } catch (err) { console.error("alert pushover failed", { endpoint: check.endpoint, kind, error: String(err) }); return false; } } type HealthCheck = { status?: string; error?: string; detail?: string }; /** The failing entries of a /healthz `checks` object ({name: {status, error?, detail?}}), if it has one. */ function failingChecks(body: string | null): [string, HealthCheck][] { if (!body) return []; try { const checks = (JSON.parse(body) as { checks?: Record }).checks; if (checks && typeof checks === "object") return Object.entries(checks).filter(([, c]) => c?.status !== "ok"); } catch { // Not JSON. } return []; } /** What failed, for the message body: the failing /healthz checks, else the raw body or the fetch error. */ export function failureLines(check: Check): string[] { if (check.error && !check.status) return [check.error]; const failing = failingChecks(check.body); if (failing.length) return failing.map(([name, c]) => `${name}: ${c?.error ?? c?.detail ?? c?.status ?? "failed"}`); return check.body ? [check.body] : []; } /** * What failed, as stable keys for comparing failures over time: the failing /healthz check names, * else the HTTP status, else "no response". Details (which change as, say, a disk fills) are left out. */ export function failureKeys(check: Check): string[] { const failing = failingChecks(check.body); if (failing.length) return failing.map(([name]) => name).sort(); return [check.status ? `HTTP ${check.status}` : "no response"]; }