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,
|
||||
};
|
||||
}
|
||||
@@ -147,6 +147,50 @@ export const ConfigSchema = z.object({
|
||||
killOnSellerKeyword: true,
|
||||
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
}),
|
||||
pushNotifications: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
|
||||
serverUrl: z.string().default('https://ntfy.sh'),
|
||||
/** Single ntfy topic every selected event is published to. */
|
||||
topic: z.string().default(''),
|
||||
/**
|
||||
* Bearer token for a protected topic. Environment-owned (NTFY_AUTH_TOKEN)
|
||||
* like the other secrets, so it is never written to the settings record.
|
||||
*/
|
||||
authToken: z.string().optional(),
|
||||
priority: z.enum(['min', 'low', 'default', 'high', 'max']).default('default'),
|
||||
events: z.object({
|
||||
imageReceived: z.boolean().default(true),
|
||||
replyReceived: z.boolean().default(false),
|
||||
messageFailed: z.boolean().default(false),
|
||||
botStopped: z.boolean().default(false),
|
||||
rateLimited: z.boolean().default(false),
|
||||
sessionExpired: z.boolean().default(true),
|
||||
syncFailed: z.boolean().default(false),
|
||||
}).default({
|
||||
imageReceived: true,
|
||||
replyReceived: false,
|
||||
messageFailed: false,
|
||||
botStopped: false,
|
||||
rateLimited: false,
|
||||
sessionExpired: true,
|
||||
syncFailed: false,
|
||||
}),
|
||||
}).default({
|
||||
enabled: false,
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: '',
|
||||
priority: 'default',
|
||||
events: {
|
||||
imageReceived: true,
|
||||
replyReceived: false,
|
||||
messageFailed: false,
|
||||
botStopped: false,
|
||||
rateLimited: false,
|
||||
sessionExpired: true,
|
||||
syncFailed: false,
|
||||
},
|
||||
}),
|
||||
openrouter: z.object({
|
||||
apiKey: z.string(),
|
||||
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
|
||||
@@ -245,6 +289,11 @@ export function loadConfig(store?: ConfigPersistence): Config {
|
||||
// its path is an environment-owned bootstrap value rather than app config.
|
||||
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
|
||||
|
||||
if (process.env.NTFY_AUTH_TOKEN?.trim()) {
|
||||
if (!rawConfig.pushNotifications) rawConfig.pushNotifications = {};
|
||||
rawConfig.pushNotifications.authToken = process.env.NTFY_AUTH_TOKEN;
|
||||
}
|
||||
|
||||
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
|
||||
if (!rawConfig.web) rawConfig.web = {};
|
||||
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { ConfigSchema, type Config } from './index.js';
|
||||
|
||||
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
|
||||
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web' | 'pushNotifications'> & {
|
||||
openrouter: Omit<Config['openrouter'], 'apiKey'>;
|
||||
web: Omit<Config['web'], 'authToken' | 'agentReadToken' | 'agentControlToken'>;
|
||||
pushNotifications: Omit<Config['pushNotifications'], 'authToken'>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -51,7 +52,8 @@ function toStoredConfig(config: Config): StoredConfig {
|
||||
agentControlToken: _agentControlToken,
|
||||
...web
|
||||
} = applicationConfig.web;
|
||||
return { ...applicationConfig, openrouter, web };
|
||||
const { authToken: _ntfyToken, ...pushNotifications } = applicationConfig.pushNotifications;
|
||||
return { ...applicationConfig, openrouter, web, pushNotifications };
|
||||
}
|
||||
|
||||
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
|
||||
@@ -59,8 +61,13 @@ function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv):
|
||||
const record = stored as Record<string, unknown>;
|
||||
const openrouter = objectValue(record.openrouter);
|
||||
const web = objectValue(record.web);
|
||||
const pushNotifications = objectValue(record.pushNotifications);
|
||||
return {
|
||||
...record,
|
||||
pushNotifications: {
|
||||
...pushNotifications,
|
||||
authToken: optionalSecret(environment.NTFY_AUTH_TOKEN),
|
||||
},
|
||||
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
|
||||
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
|
||||
web: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { RealtimeEvent } from '../web/events/event-types.js';
|
||||
|
||||
export type PushNotificationSettings = Config['pushNotifications'];
|
||||
export type PushNotificationEventKey = keyof PushNotificationSettings['events'];
|
||||
|
||||
export interface PushNotificationDraft {
|
||||
event: PushNotificationEventKey;
|
||||
title: string;
|
||||
body: string;
|
||||
/** ntfy emoji shortcodes rendered alongside the notification. */
|
||||
tags: string[];
|
||||
/** Overrides the configured priority when an event is more urgent. */
|
||||
priority?: PushNotificationSettings['priority'];
|
||||
}
|
||||
|
||||
export interface PushDeliveryResult {
|
||||
delivered: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Minimal surface of the realtime hub the notifier needs. */
|
||||
export interface PushEventSource {
|
||||
subscribe(listener: (event: RealtimeEvent) => void): { unsubscribe(): void };
|
||||
}
|
||||
|
||||
type Fetcher = (input: string, init: RequestInit) => Promise<{ ok: boolean; status: number; statusText: string }>;
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_BODY_LENGTH = 400;
|
||||
|
||||
/**
|
||||
* Maps a realtime event onto the push notification an operator would want on
|
||||
* their phone, or null when the event is not notification-worthy. Pure, so the
|
||||
* copy and the event selection are testable without any transport.
|
||||
*/
|
||||
export function pushNotificationForEvent(event: RealtimeEvent): PushNotificationDraft | null {
|
||||
switch (event.type) {
|
||||
case 'media.received':
|
||||
return {
|
||||
event: 'imageReceived',
|
||||
title: `Image from u/${event.payload.sender}`,
|
||||
body: event.payload.count > 1
|
||||
? `${event.payload.count} images are attached to the conversation.`
|
||||
: 'An image is attached to the conversation.',
|
||||
tags: ['camera'],
|
||||
};
|
||||
case 'reply.received':
|
||||
return {
|
||||
event: 'replyReceived',
|
||||
title: `Reply from u/${event.payload.sender}`,
|
||||
body: event.payload.preview?.trim() || 'A new message arrived in the conversation.',
|
||||
tags: ['speech_balloon'],
|
||||
};
|
||||
case 'message.failed':
|
||||
return {
|
||||
event: 'messageFailed',
|
||||
title: `Message to u/${event.payload.recipient} failed`,
|
||||
body: event.payload.reason,
|
||||
tags: ['warning'],
|
||||
};
|
||||
case 'bot.status.changed':
|
||||
if (event.payload.status !== 'stopped') return null;
|
||||
return {
|
||||
event: 'botStopped',
|
||||
title: 'Bot stopped',
|
||||
body: event.payload.reason ?? 'The bot is no longer processing candidates.',
|
||||
tags: ['octagonal_sign'],
|
||||
};
|
||||
case 'rate-limit.changed':
|
||||
if (!event.payload.limited) return null;
|
||||
return {
|
||||
event: 'rateLimited',
|
||||
title: 'Rate limit reached',
|
||||
body: event.payload.reason ?? 'Sending is paused until the limit resets.',
|
||||
tags: ['hourglass'],
|
||||
};
|
||||
case 'reddit.session.changed':
|
||||
if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
|
||||
return {
|
||||
event: 'sessionExpired',
|
||||
title: 'Reddit account needs attention',
|
||||
body: event.payload.reason
|
||||
?? (event.payload.username
|
||||
? `The session for u/${event.payload.username} is ${event.payload.status}.`
|
||||
: `The Reddit session is ${event.payload.status}.`),
|
||||
tags: ['rotating_light'],
|
||||
priority: 'high',
|
||||
};
|
||||
case 'sync.failed':
|
||||
return {
|
||||
event: 'syncFailed',
|
||||
title: `${event.payload.kind} sync failed`,
|
||||
body: event.payload.reason,
|
||||
tags: ['warning'],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes selected application events to a single ntfy topic so they reach
|
||||
* the operator's phone. Delivery is best-effort: a failing or unreachable ntfy
|
||||
* server is logged and never propagated into the calling path.
|
||||
*/
|
||||
export class PushNotifier {
|
||||
private subscription: { unsubscribe(): void } | null = null;
|
||||
private attachedAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly getSettings: () => PushNotificationSettings,
|
||||
private readonly logger: Logger,
|
||||
private readonly fetcher: Fetcher = globalThis.fetch as unknown as Fetcher,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Subscribes to the realtime hub. Events buffered before this call are
|
||||
* skipped: the hub replays its buffer to every new subscriber, and replaying
|
||||
* a restart's worth of history to somebody's phone is not useful.
|
||||
*/
|
||||
attach(source: PushEventSource): void {
|
||||
if (this.subscription) return;
|
||||
this.attachedAt = this.now();
|
||||
this.subscription = source.subscribe((event) => {
|
||||
if (Date.parse(event.occurredAt) < this.attachedAt) return;
|
||||
const draft = pushNotificationForEvent(event);
|
||||
if (!draft) return;
|
||||
void this.publish(draft);
|
||||
});
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.subscription?.unsubscribe();
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
/** Sends a notification if the event is enabled and the topic is configured. */
|
||||
async publish(draft: PushNotificationDraft): Promise<PushDeliveryResult> {
|
||||
const settings = this.getSettings();
|
||||
if (!settings.enabled) return { delivered: false, reason: 'Push notifications are disabled.' };
|
||||
if (!settings.events[draft.event]) {
|
||||
return { delivered: false, reason: `The ${draft.event} event is not selected.` };
|
||||
}
|
||||
return this.deliver(draft, settings);
|
||||
}
|
||||
|
||||
/** Sends a fixed notification regardless of the per-event selection. */
|
||||
sendTest(): Promise<PushDeliveryResult> {
|
||||
const settings = this.getSettings();
|
||||
if (!settings.enabled) return Promise.resolve({ delivered: false, reason: 'Push notifications are disabled.' });
|
||||
return this.deliver({
|
||||
event: 'imageReceived',
|
||||
title: 'RedditBot test notification',
|
||||
body: 'Push notifications are configured correctly.',
|
||||
tags: ['white_check_mark'],
|
||||
}, settings);
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
draft: PushNotificationDraft,
|
||||
settings: PushNotificationSettings,
|
||||
): Promise<PushDeliveryResult> {
|
||||
const topic = settings.topic.trim();
|
||||
if (topic === '') return { delivered: false, reason: 'No ntfy topic is configured.' };
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = new URL(topic, `${settings.serverUrl.trim().replace(/\/+$/, '')}/`).toString();
|
||||
} catch {
|
||||
return { delivered: false, reason: `Invalid ntfy server URL: ${settings.serverUrl}` };
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Title: headerSafe(draft.title),
|
||||
Priority: draft.priority ?? settings.priority,
|
||||
Tags: draft.tags.join(','),
|
||||
};
|
||||
if (settings.authToken) headers.Authorization = `Bearer ${settings.authToken}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetcher(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: truncate(draft.body, MAX_BODY_LENGTH),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const reason = `ntfy responded ${response.status} ${response.statusText}`.trim();
|
||||
this.logger.warn(`[push] ${draft.event} notification rejected: ${reason}`);
|
||||
return { delivered: false, reason };
|
||||
}
|
||||
return { delivered: true };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`[push] ${draft.event} notification failed: ${reason}`);
|
||||
return { delivered: false, reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Titles travel as an HTTP header, so newlines (which would split the header)
|
||||
* and non-ASCII bytes are removed. The body keeps its UTF-8 text.
|
||||
*/
|
||||
function headerSafe(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ascii = value.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7E]/g, '').trim();
|
||||
return ascii.length <= 120 ? ascii : `${ascii.slice(0, 117)}...`;
|
||||
}
|
||||
|
||||
function truncate(value: string, limit: number): string {
|
||||
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
||||
import type { MediaLibraryService } from '../services/media-library.js';
|
||||
import type { AccountContentRepository } from '../database/account-repositories.js';
|
||||
import type { PushNotifier } from '../services/push-notifier.js';
|
||||
|
||||
/** Bag of everything the route modules might need. */
|
||||
export interface WebContext {
|
||||
@@ -62,6 +63,7 @@ export interface WebContext {
|
||||
accountRuntimes?: RedditAccountRuntimeManager;
|
||||
candidateQueue?: CandidateQueueRepository;
|
||||
mediaLibrary?: MediaLibraryService;
|
||||
pushNotifier?: PushNotifier;
|
||||
};
|
||||
|
||||
/** Bot pause/resume toggle read by the main loop. */
|
||||
|
||||
@@ -28,6 +28,9 @@ export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
|
||||
reply(input: { conversationId: string; id: string; username: string; body?: string }): Published {
|
||||
return hub.publish('reply.received', { conversationId: input.conversationId, replyId: input.id, sender: input.username, preview: input.body });
|
||||
},
|
||||
mediaReceived(input: { conversationId: string; username: string; count: number }): Published {
|
||||
return hub.publish('media.received', { conversationId: input.conversationId, sender: input.username, count: input.count });
|
||||
},
|
||||
sync(input: SyncInput): Published {
|
||||
const kind = syncKind(input.kind);
|
||||
switch (input.phase) {
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface RealtimeEventMap {
|
||||
sender: string;
|
||||
preview?: string;
|
||||
};
|
||||
'media.received': {
|
||||
conversationId: string;
|
||||
sender: string;
|
||||
/** Number of inbound media attachments in the conversation. */
|
||||
count: number;
|
||||
};
|
||||
'sync.started': {
|
||||
syncId: string;
|
||||
kind: SyncKind;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
createDashboardEventAdapters,
|
||||
createDashboardEventPublisher,
|
||||
RealtimeEventHub,
|
||||
type RealtimeEventListener,
|
||||
type RealtimeEventSubscription,
|
||||
} from './events/index.js';
|
||||
import { createExpressSseHandler } from './events/express-sse-adapter.js';
|
||||
import { toJsonSafe } from '../utils/json-safe.js';
|
||||
@@ -28,6 +30,14 @@ export class LogBroadcaster {
|
||||
this.dashboardEvents = createDashboardEventAdapters(createDashboardEventPublisher(this.eventHub));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe an in-process consumer (such as the push notifier) to the same
|
||||
* typed event stream the dashboard's SSE clients receive.
|
||||
*/
|
||||
subscribe(listener: RealtimeEventListener): RealtimeEventSubscription {
|
||||
return this.eventHub.subscribe(listener);
|
||||
}
|
||||
|
||||
push(level: LogEntry['level'], message: string, data?: unknown): void {
|
||||
const safeData = toJsonSafe(data);
|
||||
const realtimeEvent = this.eventHub.closed
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { WebContext } from '../context.js';
|
||||
import type { PushNotificationSettings } from '../../services/push-notifier.js';
|
||||
|
||||
/** ntfy restricts topic names to this alphabet. */
|
||||
const TOPIC_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
|
||||
|
||||
const ServerUrlSchema = z.string().trim().refine((value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Server URL must be an http:// or https:// address.');
|
||||
|
||||
const TopicSchema = z.string().trim().refine(
|
||||
(value) => value === '' || TOPIC_PATTERN.test(value),
|
||||
'Topic may only contain letters, numbers, hyphens and underscores.',
|
||||
);
|
||||
|
||||
const PushNotificationSettingsUpdateSchema = z.strictObject({
|
||||
enabled: z.boolean().optional(),
|
||||
serverUrl: ServerUrlSchema.optional(),
|
||||
topic: TopicSchema.optional(),
|
||||
priority: z.enum(['min', 'low', 'default', 'high', 'max']).optional(),
|
||||
events: z.strictObject({
|
||||
imageReceived: z.boolean(),
|
||||
replyReceived: z.boolean(),
|
||||
messageFailed: z.boolean(),
|
||||
botStopped: z.boolean(),
|
||||
rateLimited: z.boolean(),
|
||||
sessionExpired: z.boolean(),
|
||||
syncFailed: z.boolean(),
|
||||
}).partial().optional(),
|
||||
});
|
||||
|
||||
type PushNotificationSettingsResponse = Omit<PushNotificationSettings, 'authToken'> & {
|
||||
hasAuthToken: boolean;
|
||||
};
|
||||
|
||||
/** The stored token is environment-owned and is never returned to a client. */
|
||||
function settingsResponse(ctx: WebContext): PushNotificationSettingsResponse {
|
||||
const settings = ctx.config.pushNotifications;
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
serverUrl: settings.serverUrl,
|
||||
topic: settings.topic,
|
||||
priority: settings.priority,
|
||||
events: { ...settings.events },
|
||||
hasAuthToken: Boolean(settings.authToken),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPushNotificationsRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/push-notifications', (_req, res) => {
|
||||
res.json(settingsResponse(ctx));
|
||||
});
|
||||
|
||||
router.put('/push-notifications', (req, res) => {
|
||||
const parsed = PushNotificationSettingsUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid push notification settings', detail: parsed.error.format() });
|
||||
return;
|
||||
}
|
||||
|
||||
const configStore = ctx.services.configStore;
|
||||
if (!configStore) {
|
||||
res.status(503).json({ error: 'Configuration persistence is unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates = parsed.data;
|
||||
if (updates.enabled === true) {
|
||||
const topic = (updates.topic ?? ctx.config.pushNotifications.topic).trim();
|
||||
if (topic === '') {
|
||||
res.status(400).json({ error: 'A topic is required before push notifications can be enabled' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Assigned field by field, never replaced: the notifier holds a reference
|
||||
// to this object and reads it on every event, so a save applies without a
|
||||
// restart.
|
||||
const settings = ctx.config.pushNotifications;
|
||||
if (updates.enabled !== undefined) settings.enabled = updates.enabled;
|
||||
if (updates.serverUrl !== undefined) settings.serverUrl = updates.serverUrl;
|
||||
if (updates.topic !== undefined) settings.topic = updates.topic;
|
||||
if (updates.priority !== undefined) settings.priority = updates.priority;
|
||||
if (updates.events) Object.assign(settings.events, updates.events);
|
||||
|
||||
try {
|
||||
configStore.save(ctx.config);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to persist push notification settings:', error);
|
||||
res.status(500).json({ error: String(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.activity.push('info', 'Push notification settings updated via dashboard');
|
||||
ctx.broadcaster.push('info', 'Push notification settings updated', {
|
||||
enabled: settings.enabled,
|
||||
topic: settings.topic,
|
||||
});
|
||||
|
||||
res.json(settingsResponse(ctx));
|
||||
});
|
||||
|
||||
router.post('/push-notifications/test', async (_req, res) => {
|
||||
const notifier = ctx.services.pushNotifier;
|
||||
if (!notifier) {
|
||||
res.status(503).json({ error: 'Push notifications are unavailable' });
|
||||
return;
|
||||
}
|
||||
const result = await notifier.sendTest();
|
||||
res.json({ delivered: result.delivered, ...(result.reason ? { reason: result.reason } : {}) });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { MediaSyncService } from '../services/media-sync.js';
|
||||
import type { ResponseCheckerService } from '../services/response-checker.js';
|
||||
import type { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { PushNotifier } from '../services/push-notifier.js';
|
||||
import type { StrategySelector } from '../services/strategy-selector.js';
|
||||
import type { BrowserClient } from '../browser/client.js';
|
||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||
@@ -38,6 +39,7 @@ import { createDebugRouter } from './routes/debug.js';
|
||||
import { createLogsRouter } from './routes/logs.js';
|
||||
import { createAnalyticsRouter } from './routes/analytics.js';
|
||||
import { createConfigRouter } from './routes/config.js';
|
||||
import { createPushNotificationsRouter } from './routes/push-notifications.js';
|
||||
import { createLoginRouter } from './routes/login.js';
|
||||
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
|
||||
import { resolveDashboardAssets } from './dashboard-assets.js';
|
||||
@@ -178,6 +180,7 @@ export class WebServer {
|
||||
this.app.use('/api', createLogsRouter(ctx));
|
||||
this.app.use('/api', createAnalyticsRouter(ctx));
|
||||
this.app.use('/api', createConfigRouter(ctx));
|
||||
this.app.use('/api', createPushNotificationsRouter(ctx));
|
||||
this.app.use('/api', createLoginRouter(ctx));
|
||||
|
||||
this.app.use('/api', (_req, res) => {
|
||||
@@ -260,6 +263,10 @@ export class WebServer {
|
||||
this.services.configStore = configStore;
|
||||
}
|
||||
|
||||
setPushNotifier(pushNotifier: PushNotifier): void {
|
||||
this.services.pushNotifier = pushNotifier;
|
||||
}
|
||||
|
||||
setRedditAccountsService(accounts: RedditAccountsService): void {
|
||||
this.services.redditAccounts = accounts;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user