summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/routes/notifications.ts
blob: 473e837ae2445f5cebe4c148a277664a1d830dfc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// `/notifications` — ntfy.sh config + test-send route.

import {
	defaultNtfyConfig,
	loadNtfyConfig,
	type NotificationEventType,
	NTFY_EVENT_TYPES,
	type NtfyConfig,
	normalizeNtfyConfig,
	redactNtfyConfig,
	saveNtfyConfig,
	sendNtfy,
} from "@dispatch/core";
import { Hono } from "hono";

export const notificationsRoutes = new Hono();

notificationsRoutes.get("/", (c) => {
	const config = loadNtfyConfig();
	return c.json({
		config: redactNtfyConfig(config),
		eventTypes: NTFY_EVENT_TYPES,
		defaults: defaultNtfyConfig(),
	});
});

notificationsRoutes.put("/", async (c) => {
	const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>();
	const existing = loadNtfyConfig();

	// `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep
	// the existing token (the GET response redacts it, so the frontend doesn't
	// have it to send back). Any other string ⇒ replace.
	let nextAuthToken = existing.authToken;
	if (typeof body.authToken === "string") nextAuthToken = body.authToken;

	const merged = normalizeNtfyConfig({
		enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled,
		topic: typeof body.topic === "string" ? body.topic : existing.topic,
		authToken: nextAuthToken,
		events: { ...existing.events, ...(body.events ?? {}) },
		notifySubagents:
			typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents,
	});

	// Only validation: if notifications are turned on, the topic must be
	// non-empty. Any other "is this a valid ntfy topic name?" check is
	// punted to the ntfy server itself — its rules vary and have changed
	// over time, and a syntactically-valid name still might be rejected
	// (e.g. reserved words), so a clear server error is more useful than
	// a client-side guess.
	if (merged.enabled && !merged.topic.trim()) {
		return c.json({ error: "Topic is required" }, 400);
	}

	saveNtfyConfig(merged);
	return c.json({ config: redactNtfyConfig(merged) });
});

notificationsRoutes.post("/test", async (c) => {
	const config = loadNtfyConfig();
	if (!config.enabled) {
		return c.json({ ok: false, error: "Notifications are disabled" }, 400);
	}
	if (!config.topic.trim()) {
		return c.json({ ok: false, error: "Topic is required" }, 400);
	}

	// Use a real event type so the per-event toggle is honored when wiring
	// is tested end-to-end; pick `turn-completed` since it's the most
	// common enabled-by-default event.
	const eventType: NotificationEventType = "turn-completed";
	if (!config.events[eventType]) {
		return c.json(
			{ ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` },
			400,
		);
	}

	const result = await sendNtfy(config, {
		type: eventType,
		title: "Dispatch test notification",
		message: "If you can see this, ntfy.sh notifications are wired up correctly.",
		tags: ["bell"],
	});
	if (!result.ok) return c.json(result, 502);
	return c.json(result);
});