pushover.ts
1import type { AlertKind } from "./email";
2import type { Check } from "./monitor";
3
4const API = "https://api.pushover.net/1/messages.json";
5/** Pushover's limits: title 250 characters, message 1024. */
6const MAX_TITLE = 250;
7const MAX_MESSAGE = 1024;
8
9/** Optional secrets; the channel is off until both are set. */
10type PushoverEnv = Env & { PUSHOVER_TOKEN?: string; PUSHOVER_USER?: string };
11
12export function pushoverEnabled(env: Env): boolean {
13 const e = env as PushoverEnv;
14 return Boolean(e.PUSHOVER_TOKEN && e.PUSHOVER_USER);
15}
16
17/**
18 * Sends an alert as a Pushover notification, the second channel next to email. Returns false (and
19 * logs) on failure rather than throwing. Call only when pushoverEnabled().
20 */
21export async function sendPushover(env: Env, kind: AlertKind, check: Check, failingSince: number | null): Promise<boolean> {
22 const e = env as PushoverEnv;
23 const host = check.endpoint.replace(/^https?:\/\//, "");
24 const result = check.status ? `HTTP ${check.status}` : (check.error ?? "no response");
25 const title = `${kind.toUpperCase()}: ${host} (${result}) from ${check.region}`;
26 const lines: string[] = [];
27 if (failingSince !== null) lines.push(`Failing since ${new Date(failingSince).toISOString()}`);
28 if (kind !== "recovered") lines.push(...failureLines(check));
29 const form = new URLSearchParams({
30 token: e.PUSHOVER_TOKEN!,
31 user: e.PUSHOVER_USER!,
32 title: title.slice(0, MAX_TITLE),
33 message: (lines.join("\n") || result).slice(0, MAX_MESSAGE),
34 // Recoveries arrive quietly; failures make a sound.
35 priority: kind === "recovered" ? "-1" : "0",
36 url: `${env.PUBLIC_URL}/endpoint?url=${encodeURIComponent(check.endpoint)}`,
37 url_title: "History",
38 timestamp: String(Math.floor(check.ts / 1000)),
39 });
40 try {
41 const res = await fetch(API, { method: "POST", body: form, signal: AbortSignal.timeout(Number(env.TIMEOUT_MS)) });
42 const reply = await res.text();
43 if (!res.ok || !reply.includes('"status":1')) throw new Error(`HTTP ${res.status}: ${reply.slice(0, 200)}`);
44 console.log("alert pushover sent", { endpoint: check.endpoint, kind, title });
45 return true;
46 } catch (err) {
47 console.error("alert pushover failed", { endpoint: check.endpoint, kind, error: String(err) });
48 return false;
49 }
50}
51
52/**
53 * What failed, for the message body: the failing entries of a /healthz `checks` object
54 * ({name: {status, error?, detail?}}), else the raw body or the fetch error.
55 */
56export function failureLines(check: Check): string[] {
57 if (check.error && !check.status) return [check.error];
58 if (!check.body) return [];
59 try {
60 const checks = (JSON.parse(check.body) as { checks?: Record<string, { status?: string; error?: string; detail?: string }> }).checks;
61 if (checks && typeof checks === "object") {
62 const failing = Object.entries(checks)
63 .filter(([, c]) => c?.status !== "ok")
64 .map(([name, c]) => `${name}: ${c?.error ?? c?.detail ?? c?.status ?? "failed"}`);
65 if (failing.length) return failing;
66 }
67 } catch {
68 // Not JSON: fall through to the raw body.
69 }
70 return [check.body];
71}