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

+49
View File
@@ -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;
+9 -2
View File
@@ -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: {