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>
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
import type Database from 'better-sqlite3';
|
|
import { ConfigSchema, type Config } from './index.js';
|
|
|
|
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'>;
|
|
};
|
|
|
|
/**
|
|
* Persists application-owned configuration in SQLite. Secrets and the
|
|
* database location remain environment-owned bootstrap settings and are
|
|
* deliberately never written to the settings record.
|
|
*/
|
|
export class ConfigStore {
|
|
constructor(private readonly db: Database.Database) {}
|
|
|
|
load(environment: NodeJS.ProcessEnv = process.env): Config | null {
|
|
const row = this.db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as
|
|
| { config_json: string }
|
|
| undefined;
|
|
if (!row) return null;
|
|
|
|
let stored: unknown;
|
|
try {
|
|
stored = JSON.parse(row.config_json);
|
|
} catch (error) {
|
|
throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
|
|
}
|
|
|
|
return validateConfig(withBootstrapSettings(stored, environment));
|
|
}
|
|
|
|
save(config: Config): void {
|
|
const stored = toStoredConfig(config);
|
|
this.db.prepare(`
|
|
INSERT INTO app_config (id, config_json, updated_at)
|
|
VALUES (1, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
config_json = excluded.config_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(JSON.stringify(stored), Date.now());
|
|
}
|
|
}
|
|
|
|
function toStoredConfig(config: Config): StoredConfig {
|
|
const { database: _database, ...applicationConfig } = config;
|
|
const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
|
|
const {
|
|
authToken: _authToken,
|
|
agentReadToken: _agentReadToken,
|
|
agentControlToken: _agentControlToken,
|
|
...web
|
|
} = applicationConfig.web;
|
|
const { authToken: _ntfyToken, ...pushNotifications } = applicationConfig.pushNotifications;
|
|
return { ...applicationConfig, openrouter, web, pushNotifications };
|
|
}
|
|
|
|
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
|
|
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return stored;
|
|
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: {
|
|
...web,
|
|
authToken: environment.WEB_DASHBOARD_TOKEN,
|
|
agentReadToken: optionalSecret(environment.REDDIT_BOT_AGENT_READ_TOKEN),
|
|
agentControlToken: optionalSecret(environment.REDDIT_BOT_AGENT_CONTROL_TOKEN),
|
|
},
|
|
};
|
|
}
|
|
|
|
function objectValue(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function optionalSecret(value: string | undefined): string | undefined {
|
|
return value?.trim() ? value : undefined;
|
|
}
|
|
|
|
function validateConfig(raw: unknown): Config {
|
|
const result = ConfigSchema.safeParse(raw);
|
|
if (!result.success) {
|
|
const errors = result.error.issues
|
|
.map((issue) => ` - ${String(issue.path.join('.'))}: ${issue.message}`)
|
|
.join('\n');
|
|
throw new Error(`Invalid stored configuration:\n${errors}`);
|
|
}
|
|
if (result.data.web.enabled && !result.data.web.authToken) {
|
|
throw new Error('WEB_DASHBOARD_TOKEN environment variable is required when the dashboard is enabled');
|
|
}
|
|
return result.data;
|
|
}
|