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

+2
View File
@@ -7,6 +7,8 @@ export type {
SyncPhase,
SyncCompletion,
SyncProgressListener,
NewReplyListener,
MediaReceivedListener,
ConversationStartResult,
} from './service.js';
export type {
+28 -2
View File
@@ -135,6 +135,13 @@ export type NewReplyListener = (info: {
replySnippet: string;
}) => void;
/** Fired the first time a conversation we opened receives inbound media. */
export type MediaReceivedListener = (info: {
conversationId: string;
username: string;
count: number;
}) => void;
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
@@ -147,6 +154,7 @@ export class ChatService {
private progress: SyncProgress | null = null;
private progressListener: SyncProgressListener | null = null;
private newReplyListener: NewReplyListener | null = null;
private mediaReceivedListener: MediaReceivedListener | null = null;
private invitationCooldown: InvitationCooldown | null = null;
constructor(
@@ -535,6 +543,11 @@ export class ChatService {
this.newReplyListener = listener;
}
/** Subscribe to inbound-media notifications (fires once per chat, on the first image). */
setMediaReceivedListener(listener: MediaReceivedListener | null): void {
this.mediaReceivedListener = listener;
}
private beginSync(kind: 'full' | 'delta'): void {
this.syncingKind = kind;
this.progress = {
@@ -1167,12 +1180,25 @@ export class ChatService {
);
const incomingMessages = conversationMessages.filter((message) => !message.isSelf);
const hasReply = incomingMessages.length > 0;
const hasReceivedImage = incomingMessages.some(
(message) => (message.media?.length ?? 0) > 0,
const incomingMediaCount = incomingMessages.reduce(
(total, message) => total + (message.media?.length ?? 0),
0,
);
const hasReceivedImage = incomingMediaCount > 0;
const firstAttributedReply = hasReply && !performanceMessage.gotResponse;
if (hasReceivedImage && !performanceMessage.gotImage) {
this.messagesRepo.markGotImage(performanceMessage.id);
// Guarded by the durable `gotImage` flag, so re-syncing a conversation
// cannot re-notify for media that was already reported.
try {
this.mediaReceivedListener?.({
conversationId: chat.roomId,
username: chat.username,
count: incomingMediaCount,
});
} catch (err) {
this.logger.warn('[chat] mediaReceivedListener threw:', err);
}
}
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {