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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user