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

+14
View File
@@ -39,6 +39,7 @@ import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { PushNotifier } from './services/push-notifier.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
@@ -88,6 +89,7 @@ export interface AppContainer {
mediaSyncService: MediaSyncService;
mediaLibraryService: MediaLibraryService;
rateLimiter: RateLimiter;
pushNotifier: PushNotifier;
webServer: WebServer | null;
}
@@ -117,6 +119,11 @@ export async function bootstrap(
attachLogSink(logger, broadcaster);
}
// Phone push notifications ride the same typed event stream the dashboard
// consumes, so every notifiable event has exactly one publisher.
const pushNotifier = new PushNotifier(() => config.pushNotifications, logger);
pushNotifier.attach(broadcaster);
// Database
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
@@ -423,6 +430,11 @@ export async function bootstrap(
lastSyncProgress = null;
}
});
chatService.setMediaReceivedListener(({ conversationId, username, count }) => {
webServer!.logActivity('response', `Image received from u/${username}`, username);
webServer!.pushLog('info', `🖼️ Image from u/${username}`);
broadcaster.dashboardEvents.mediaReceived({ conversationId, username, count });
});
chatService.setNewReplyListener(({ conversationId, replyId, username, replySnippet }) => {
webServer!.logActivity(
'response',
@@ -453,6 +465,7 @@ export async function bootstrap(
webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setPushNotifier(pushNotifier);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -500,6 +513,7 @@ export async function bootstrap(
mediaSyncService,
mediaLibraryService,
rateLimiter,
pushNotifier,
webServer,
};
}