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,140 @@
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
|
||||
import { ChatCache } from '../src/services/chat/cache.js';
|
||||
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
|
||||
import { ChatService } from '../src/services/chat/service.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
|
||||
|
||||
const credentials = {
|
||||
homeserver: 'https://matrix.example.test',
|
||||
accessToken: 'token',
|
||||
userId: '@me:example.test',
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const openDatabases: Array<ReturnType<typeof createTestDb>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const database of openDatabases.splice(0)) database.close();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
|
||||
fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('ChatService inbound media notifications', () => {
|
||||
it('reports an inbound image exactly once, however often the chat re-syncs', async () => {
|
||||
const fixture = await createFixture({ inboundMediaCount: 2 });
|
||||
const received: unknown[] = [];
|
||||
fixture.service.setMediaReceivedListener((info) => received.push(info));
|
||||
|
||||
await fixture.service.runStartupSync();
|
||||
|
||||
expect(received).toEqual([{ conversationId: fixture.roomId, username: 'alice', count: 2 }]);
|
||||
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
|
||||
|
||||
await fixture.service.runStartupSync();
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stays quiet for a text-only conversation', async () => {
|
||||
const fixture = await createFixture({ inboundMediaCount: 0 });
|
||||
const received: unknown[] = [];
|
||||
fixture.service.setMediaReceivedListener((info) => received.push(info));
|
||||
|
||||
await fixture.service.runStartupSync();
|
||||
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it('records the image even when the listener throws', async () => {
|
||||
const fixture = await createFixture({ inboundMediaCount: 1 });
|
||||
fixture.service.setMediaReceivedListener(() => { throw new Error('notifier exploded'); });
|
||||
|
||||
await fixture.service.runStartupSync();
|
||||
|
||||
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
|
||||
});
|
||||
});
|
||||
|
||||
async function createFixture(options: { inboundMediaCount: number }) {
|
||||
const logger = createMockLogger();
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-notify-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const cacheFile = path.join(directory, 'chat-cache.json');
|
||||
const db = createTestDb();
|
||||
openDatabases.push(db);
|
||||
const repos = createTestRepositories(db);
|
||||
const outcomes = new ConversationOutcomesRepository(db);
|
||||
const conversationStates = new ConversationStateRepository(db);
|
||||
const now = Date.now();
|
||||
const roomId = '!alice:example.test';
|
||||
|
||||
repos.postsRepo.insert({
|
||||
id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source',
|
||||
imageUrl: null, processedAt: now - 10_000,
|
||||
});
|
||||
repos.messagesRepo.insert({
|
||||
postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'Hello',
|
||||
sentAt: now - 9_000, strategyId: 'direct', chatRoomId: roomId,
|
||||
});
|
||||
|
||||
const media = Array.from({ length: options.inboundMediaCount }, (_unused, index) => ({
|
||||
originalUrl: `mxc://example.test/image-${index}`,
|
||||
localPath: `/data/media/image-${index}.jpg`,
|
||||
fileName: `image-${index}.jpg`,
|
||||
}));
|
||||
|
||||
await fs.writeFile(cacheFile, JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: now - 60_000,
|
||||
chats: [{
|
||||
username: 'alice',
|
||||
roomName: 'alice',
|
||||
lastMessageTime: now - 2_000,
|
||||
lastMessageSnippet: '[Image]',
|
||||
isFromUs: false,
|
||||
unread: false,
|
||||
roomId,
|
||||
messages: [
|
||||
{ sender: 'Me', text: 'Hello', timestamp: now - 5_000, isSelf: true },
|
||||
{
|
||||
sender: 'alice',
|
||||
text: media.length > 0 ? '[Image]' : 'Thanks!',
|
||||
timestamp: now - 2_000,
|
||||
isSelf: false,
|
||||
...(media.length > 0 ? { media } : {}),
|
||||
},
|
||||
],
|
||||
}],
|
||||
nextBatch: 's1',
|
||||
lastFullSyncAt: now - 120_000,
|
||||
lastDeltaSyncAt: now - 60_000,
|
||||
cooldown: null,
|
||||
}), 'utf8');
|
||||
|
||||
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
|
||||
matrix.sync = vi.fn(async (): Promise<MatrixSyncResponse> => ({ next_batch: 's2' })) as typeof matrix.sync;
|
||||
const cache = new ChatCache(logger, cacheFile);
|
||||
const service = new ChatService(
|
||||
matrix,
|
||||
logger,
|
||||
repos.successfulMessagesRepo,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
cache,
|
||||
repos.messagesRepo,
|
||||
undefined,
|
||||
authenticatedAccountAuth,
|
||||
outcomes,
|
||||
conversationStates,
|
||||
);
|
||||
await service.loadCache();
|
||||
|
||||
return { repos, roomId, service };
|
||||
}
|
||||
@@ -44,6 +44,7 @@ describe('ConfigStore', () => {
|
||||
config.web.authToken = 'also-secret';
|
||||
config.web.agentReadToken = 'read-agent-secret';
|
||||
config.web.agentControlToken = 'control-agent-secret';
|
||||
config.pushNotifications.authToken = 'ntfy-secret';
|
||||
store.save(config);
|
||||
|
||||
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
|
||||
@@ -51,9 +52,31 @@ describe('ConfigStore', () => {
|
||||
expect(row.config_json).not.toContain('also-secret');
|
||||
expect(row.config_json).not.toContain('read-agent-secret');
|
||||
expect(row.config_json).not.toContain('control-agent-secret');
|
||||
expect(row.config_json).not.toContain('ntfy-secret');
|
||||
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
|
||||
});
|
||||
|
||||
it('takes the ntfy access token from the environment on every load', () => {
|
||||
const config = getTestConfig();
|
||||
config.pushNotifications.topic = 'redditbot-7f3a9c21';
|
||||
config.pushNotifications.authToken = 'saved-token-must-be-ignored';
|
||||
store.save(config);
|
||||
|
||||
const withToken = store.load({
|
||||
OPENROUTER_API_KEY: 'environment-key',
|
||||
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
||||
NTFY_AUTH_TOKEN: 'environment-ntfy-token',
|
||||
});
|
||||
const withoutToken = store.load({
|
||||
OPENROUTER_API_KEY: 'environment-key',
|
||||
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
||||
});
|
||||
|
||||
expect(withToken?.pushNotifications.topic).toBe('redditbot-7f3a9c21');
|
||||
expect(withToken?.pushNotifications.authToken).toBe('environment-ntfy-token');
|
||||
expect(withoutToken?.pushNotifications.authToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates the singleton record instead of appending versions', () => {
|
||||
store.save(getTestConfig());
|
||||
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
|
||||
|
||||
const config = {
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||
browser: { dryRun: true },
|
||||
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
|
||||
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
|
||||
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
|
||||
};
|
||||
|
||||
function pushSettings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let settings = pushSettings();
|
||||
let saved: unknown[] = [];
|
||||
|
||||
const server = setupServer(
|
||||
http.get('/api/config', () => HttpResponse.json(config)),
|
||||
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||
http.get('/api/push-notifications', () => HttpResponse.json(settings)),
|
||||
http.put('/api/push-notifications', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
saved.push(body);
|
||||
settings = pushSettings({ ...body, events: { ...settings.events, ...(body.events as object) } });
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
);
|
||||
|
||||
function renderSettings() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const router = createMemoryRouter([{ path: '/settings', element: <SettingsRoute /> }], {
|
||||
initialEntries: ['/settings?section=notifications'],
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function panel() {
|
||||
return screen.getByRole('region', { name: 'Notifications' });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
beforeEach(() => {
|
||||
settings = pushSettings();
|
||||
saved = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('push notification settings', () => {
|
||||
it('shows the notifications section with every selectable event', async () => {
|
||||
renderSettings();
|
||||
|
||||
const section = await screen.findByRole('region', { name: 'Notifications' });
|
||||
expect(within(section).getByLabelText(/Server URL/)).toHaveValue('https://ntfy.sh');
|
||||
expect(within(section).getByRole('group', { name: /Events to notify me about \(2 of 7\)/ })).toBeInTheDocument();
|
||||
expect(within(section).getByRole('checkbox', { name: /Image received/ })).toBeChecked();
|
||||
expect(within(section).getByRole('checkbox', { name: /Reply received/ })).not.toBeChecked();
|
||||
expect(within(section).getByText(/No access token is configured/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves the topic and event selection the operator picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
|
||||
await user.type(within(panel()).getByLabelText(/Topic/), 'redditbot-7f3a9c21');
|
||||
await user.click(within(panel()).getByRole('checkbox', { name: /Reply received/ }));
|
||||
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
|
||||
|
||||
await waitFor(() => expect(saved).toHaveLength(1));
|
||||
expect(saved[0]).toMatchObject({
|
||||
enabled: true,
|
||||
topic: 'redditbot-7f3a9c21',
|
||||
events: { imageReceived: true, replyReceived: true },
|
||||
});
|
||||
expect(await within(panel()).findByText('Notification settings saved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses to save an enabled configuration with no topic', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
|
||||
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
|
||||
|
||||
expect(within(panel()).getByText(/A topic is required/)).toBeInTheDocument();
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a topic containing characters ntfy will not accept', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.type(within(panel()).getByLabelText(/Topic/), 'not a topic!');
|
||||
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
|
||||
|
||||
expect(within(panel()).getByText(/only contain letters, numbers/)).toBeInTheDocument();
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sends a test notification and reports the delivery outcome', async () => {
|
||||
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
|
||||
server.use(http.post('/api/push-notifications/test', () => HttpResponse.json({
|
||||
delivered: false,
|
||||
reason: 'ntfy responded 403 Forbidden',
|
||||
})));
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.click(within(panel()).getByRole('button', { name: /Send test notification/ }));
|
||||
|
||||
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('will not send a test until unsaved changes are saved', async () => {
|
||||
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
|
||||
|
||||
expect(within(panel()).getByRole('button', { name: /Send test notification/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('reports that an access token is configured without revealing it', async () => {
|
||||
settings = pushSettings({ hasAuthToken: true });
|
||||
renderSettings();
|
||||
|
||||
const section = await screen.findByRole('region', { name: 'Notifications' });
|
||||
expect(within(section).getByText(/An access token is configured/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,22 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
||||
variants: [],
|
||||
...overrides?.chatStrategies,
|
||||
},
|
||||
pushNotifications: {
|
||||
enabled: false,
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: '',
|
||||
priority: 'default',
|
||||
events: {
|
||||
imageReceived: true,
|
||||
replyReceived: false,
|
||||
messageFailed: false,
|
||||
botStopped: false,
|
||||
rateLimited: false,
|
||||
sessionExpired: true,
|
||||
syncFailed: false,
|
||||
},
|
||||
...overrides?.pushNotifications,
|
||||
},
|
||||
web: {
|
||||
enabled: true,
|
||||
port: 3000,
|
||||
|
||||
@@ -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