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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user