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}