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:
1 parent
827eb3a1e0
commit
38d175e4a1
34 files changed
+1728
-8
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user