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,239 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
PushNotifier,
|
||||
pushNotificationForEvent,
|
||||
type PushNotificationSettings,
|
||||
} from '../src/services/push-notifier.js';
|
||||
import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from '../src/web/events/event-types.js';
|
||||
import { createMockLogger, getLogsByLevel } from './mockLogger.js';
|
||||
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<PushNotificationSettings> = {}): PushNotificationSettings {
|
||||
return {
|
||||
enabled: true,
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'redditbot-test',
|
||||
priority: 'default',
|
||||
events: {
|
||||
imageReceived: true,
|
||||
replyReceived: true,
|
||||
messageFailed: true,
|
||||
botStopped: true,
|
||||
rateLimited: true,
|
||||
sessionExpired: true,
|
||||
syncFailed: true,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function event<TType extends RealtimeEventType>(
|
||||
type: TType,
|
||||
payload: RealtimeEventMap[TType],
|
||||
occurredAt = '2026-08-10T12:00:00.000Z',
|
||||
): RealtimeEvent {
|
||||
return { id: `${type}-1`, occurredAt, type, payload } as RealtimeEvent;
|
||||
}
|
||||
|
||||
class FakeHub {
|
||||
listener: ((event: RealtimeEvent) => void) | null = null;
|
||||
unsubscribed = false;
|
||||
|
||||
subscribe(listener: (event: RealtimeEvent) => void) {
|
||||
this.listener = listener;
|
||||
return { unsubscribe: () => { this.unsubscribed = true; this.listener = null; } };
|
||||
}
|
||||
|
||||
emit(value: RealtimeEvent): void {
|
||||
this.listener?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
describe('pushNotificationForEvent', () => {
|
||||
it('maps an inbound image onto the imageReceived notification', () => {
|
||||
expect(pushNotificationForEvent(event('media.received', {
|
||||
conversationId: 'room-1',
|
||||
sender: 'alice',
|
||||
count: 2,
|
||||
}))).toMatchObject({
|
||||
event: 'imageReceived',
|
||||
title: 'Image from u/alice',
|
||||
body: '2 images are attached to the conversation.',
|
||||
});
|
||||
});
|
||||
|
||||
it('only notifies for the actionable side of two-way status events', () => {
|
||||
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'running', dryRun: false }))).toBeNull();
|
||||
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'stopped', dryRun: false })))
|
||||
.toMatchObject({ event: 'botStopped' });
|
||||
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: false }))).toBeNull();
|
||||
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: true })))
|
||||
.toMatchObject({ event: 'rateLimited' });
|
||||
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'authenticated' }))).toBeNull();
|
||||
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'expired' })))
|
||||
.toMatchObject({ event: 'sessionExpired', priority: 'high' });
|
||||
});
|
||||
|
||||
it('ignores events that are not worth a phone notification', () => {
|
||||
expect(pushNotificationForEvent(event('log.created', { level: 'error', message: 'boom' }))).toBeNull();
|
||||
expect(pushNotificationForEvent(event('sync.progress', { syncId: 's1', kind: 'full', completed: 3 }))).toBeNull();
|
||||
expect(pushNotificationForEvent(event('message.sent', { messageId: 'm1', recipient: 'alice' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PushNotifier', () => {
|
||||
let requests: RecordedRequest[];
|
||||
let response: { ok: boolean; status: number; statusText: string };
|
||||
let fetcher: (url: string, init: RequestInit) => Promise<typeof response>;
|
||||
|
||||
beforeEach(() => {
|
||||
requests = [];
|
||||
response = { ok: true, status: 200, statusText: 'OK' };
|
||||
fetcher = (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return Promise.resolve(response);
|
||||
};
|
||||
});
|
||||
|
||||
it('posts the notification to the configured topic', async () => {
|
||||
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
|
||||
|
||||
const result = await notifier.publish({
|
||||
event: 'imageReceived',
|
||||
title: 'Image from u/alice',
|
||||
body: 'An image is attached.',
|
||||
tags: ['camera'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ delivered: true });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
|
||||
expect(requests[0].init.method).toBe('POST');
|
||||
expect(requests[0].init.body).toBe('An image is attached.');
|
||||
const headers = requests[0].init.headers as Record<string, string>;
|
||||
expect(headers.Title).toBe('Image from u/alice');
|
||||
expect(headers.Priority).toBe('default');
|
||||
expect(headers.Tags).toBe('camera');
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tolerates a trailing slash on the server URL and sends the access token when set', async () => {
|
||||
const notifier = new PushNotifier(
|
||||
() => settings({ serverUrl: 'https://ntfy.example.com/', authToken: 'tk_secret' }),
|
||||
createMockLogger(),
|
||||
fetcher,
|
||||
);
|
||||
|
||||
await notifier.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] });
|
||||
|
||||
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
|
||||
expect((requests[0].init.headers as Record<string, string>).Authorization).toBe('Bearer tk_secret');
|
||||
});
|
||||
|
||||
it('sends nothing when disabled, unselected, or missing a topic', async () => {
|
||||
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
|
||||
const unselected = new PushNotifier(
|
||||
() => settings({ events: { ...settings().events, imageReceived: false } }),
|
||||
createMockLogger(),
|
||||
fetcher,
|
||||
);
|
||||
const topicless = new PushNotifier(() => settings({ topic: ' ' }), createMockLogger(), fetcher);
|
||||
const draft = { event: 'imageReceived', title: 'Hi', body: 'There', tags: [] } as const;
|
||||
|
||||
expect(await disabled.publish({ ...draft })).toMatchObject({ delivered: false });
|
||||
expect(await unselected.publish({ ...draft })).toMatchObject({ delivered: false });
|
||||
expect(await topicless.publish({ ...draft })).toMatchObject({ delivered: false });
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports a rejected or unreachable server without throwing', async () => {
|
||||
const logger = createMockLogger();
|
||||
response = { ok: false, status: 403, statusText: 'Forbidden' };
|
||||
const rejecting = new PushNotifier(() => settings(), logger, fetcher);
|
||||
expect(await rejecting.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
|
||||
.toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' });
|
||||
|
||||
const unreachable = new PushNotifier(() => settings(), logger, () => Promise.reject(new Error('ECONNREFUSED')));
|
||||
expect(await unreachable.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
|
||||
.toEqual({ delivered: false, reason: 'ECONNREFUSED' });
|
||||
expect(getLogsByLevel(logger, 'warn')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('strips newlines from the title so the header cannot be split', async () => {
|
||||
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
|
||||
|
||||
await notifier.publish({
|
||||
event: 'replyReceived',
|
||||
title: 'Reply from u/alice\r\nX-Injected: yes',
|
||||
body: 'hello',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
expect((requests[0].init.headers as Record<string, string>).Title)
|
||||
.toBe('Reply from u/alice X-Injected: yes');
|
||||
});
|
||||
|
||||
it('sends selected hub events and skips the ones the operator turned off', async () => {
|
||||
const hub = new FakeHub();
|
||||
const notifier = new PushNotifier(
|
||||
() => settings({ events: { ...settings().events, replyReceived: false } }),
|
||||
createMockLogger(),
|
||||
fetcher,
|
||||
() => Date.parse('2026-08-10T11:00:00.000Z'),
|
||||
);
|
||||
notifier.attach(hub);
|
||||
|
||||
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
|
||||
hub.emit(event('reply.received', { conversationId: 'room-1', replyId: 'r1', sender: 'alice' }));
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
||||
|
||||
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/alice');
|
||||
});
|
||||
|
||||
it('skips buffered events the hub replays from before it attached', async () => {
|
||||
const hub = new FakeHub();
|
||||
const notifier = new PushNotifier(
|
||||
() => settings(),
|
||||
createMockLogger(),
|
||||
fetcher,
|
||||
() => Date.parse('2026-08-10T12:00:00.000Z'),
|
||||
);
|
||||
notifier.attach(hub);
|
||||
|
||||
hub.emit(event('media.received', { conversationId: 'old', sender: 'alice', count: 1 }, '2026-08-10T11:59:59.000Z'));
|
||||
hub.emit(event('media.received', { conversationId: 'new', sender: 'bob', count: 1 }, '2026-08-10T12:00:01.000Z'));
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
||||
|
||||
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/bob');
|
||||
});
|
||||
|
||||
it('sends a test notification even when no event is selected, but not when disabled', async () => {
|
||||
const noEvents = Object.fromEntries(
|
||||
Object.keys(settings().events).map((key) => [key, false]),
|
||||
) as PushNotificationSettings['events'];
|
||||
const notifier = new PushNotifier(() => settings({ events: noEvents }), createMockLogger(), fetcher);
|
||||
|
||||
expect(await notifier.sendTest()).toEqual({ delivered: true });
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
|
||||
expect(await disabled.sendTest()).toMatchObject({ delivered: false });
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops delivering once detached', async () => {
|
||||
const hub = new FakeHub();
|
||||
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher, () => 0);
|
||||
notifier.attach(hub);
|
||||
notifier.detach();
|
||||
|
||||
expect(hub.unsubscribed).toBe(true);
|
||||
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
|
||||
await Promise.resolve();
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user