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);
});
|