feat: push selected events to a phone over ntfy

Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:55:00 +01:00
1 parent 827eb3a1e0
commit 38d175e4a1
34 files changed
+1728 -8

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createPushNotificationsRouter } from '../../src/web/routes/push-notifications.js';
import { ConfigSchema, type Config } from '../../src/config/index.js';
import type { PushDeliveryResult } from '../../src/services/push-notifier.js';
import { getTestConfig } from '../testUtils.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
interface HarnessOptions {
authToken?: string;
sendTest?: () => Promise<PushDeliveryResult>;
withNotifier?: boolean;
}
function harness(options: HarnessOptions = {}) {
// Deep-cloned because zod reuses one default object across parses, so two
// harnesses would otherwise share the same `pushNotifications` instance.
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
if (options.authToken) config.pushNotifications.authToken = options.authToken;
const saved: Config[] = [];
const sendTest = vi.fn(options.sendTest ?? (() => Promise.resolve({ delivered: true })));
const ctx = {
config,
runtimeConfig: config,
services: {
configStore: { save: (next: Config) => saved.push(next) },
...(options.withNotifier === false ? {} : { pushNotifier: { sendTest } }),
},
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
} as unknown as WebContext;
const app = express();
app.use(express.json());
app.use('/api', createPushNotificationsRouter(ctx));
return { app, config, saved, sendTest };
}
function update(app: express.Express, body: unknown): Promise<Response> {
return request(app, '/api/push-notifications', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('push notification settings route', () => {
it('returns the settings with the image event on by default', async () => {
const { app } = harness();
const body = await (await request(app, '/api/push-notifications')).json();
expect(body).toEqual({
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
hasAuthToken: false,
});
});
it('never returns the access token, only whether one is configured', async () => {
const { app } = harness({ authToken: 'tk_secret' });
const response = await request(app, '/api/push-notifications');
const text = await response.text();
expect(text).not.toContain('tk_secret');
expect(JSON.parse(text).hasAuthToken).toBe(true);
});
it('persists an edited selection in place, so the running notifier sees it', async () => {
const { app, config, saved } = harness();
const settingsRef = config.pushNotifications;
const eventsRef = config.pushNotifications.events;
const response = await update(app, {
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-7f3a9c21',
priority: 'high',
events: { imageReceived: true, replyReceived: true },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-7f3a9c21',
priority: 'high',
});
// Same object references: the notifier holds this config, not a copy.
expect(config.pushNotifications).toBe(settingsRef);
expect(config.pushNotifications.events).toBe(eventsRef);
expect(config.pushNotifications.events.replyReceived).toBe(true);
// A partial events payload leaves the unmentioned selections alone.
expect(config.pushNotifications.events.sessionExpired).toBe(true);
expect(saved).toHaveLength(1);
});
it('rejects an invalid server URL, topic, or unknown field', async () => {
const { app, saved } = harness();
expect((await update(app, { serverUrl: 'ftp://ntfy.example.com' })).status).toBe(400);
expect((await update(app, { topic: 'not a topic!' })).status).toBe(400);
expect((await update(app, { webhook: 'https://example.com' })).status).toBe(400);
expect(saved).toHaveLength(0);
});
it('refuses to enable notifications without a topic', async () => {
const { app, config, saved } = harness();
const response = await update(app, { enabled: true });
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/topic is required/i);
expect(config.pushNotifications.enabled).toBe(false);
expect(saved).toHaveLength(0);
});
it('enables notifications when the topic arrives in the same save', async () => {
const { app, config } = harness();
const response = await update(app, { enabled: true, topic: 'redditbot-7f3a9c21' });
expect(response.status).toBe(200);
expect(config.pushNotifications.enabled).toBe(true);
});
it('reports the outcome of a test notification', async () => {
const { app, sendTest } = harness();
expect(await (await request(app, '/api/push-notifications/test', { method: 'POST' })).json())
.toEqual({ delivered: true });
expect(sendTest).toHaveBeenCalledTimes(1);
const failing = harness({ sendTest: () => Promise.resolve({ delivered: false, reason: 'ntfy responded 403 Forbidden' }) });
expect(await (await request(failing.app, '/api/push-notifications/test', { method: 'POST' })).json())
.toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' });
});
it('reports unavailable rather than crashing when the notifier is missing', async () => {
const { app } = harness({ withNotifier: false });
expect((await request(app, '/api/push-notifications/test', { method: 'POST' })).status).toBe(503);
});
});