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
@@ -7,6 +7,8 @@ export type {
|
||||
SyncPhase,
|
||||
SyncCompletion,
|
||||
SyncProgressListener,
|
||||
NewReplyListener,
|
||||
MediaReceivedListener,
|
||||
ConversationStartResult,
|
||||
} from './service.js';
|
||||
export type {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user