Files
RedditBot/src/config/persistence.ts
T
Jamie BruntonandClaude Opus 5 38d175e4a1 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>
2026-08-10 13:55:00 +01:00

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;
}