feat: persist settings and enrich conversation context
This commit is contained in:
1 parent
a36723a325
commit
2c100d72b6
84 files changed
+2248
-644
No files matched your search
+20
-1
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
export interface ConfigPersistence {
|
||||
load(environment?: NodeJS.ProcessEnv): Config | null;
|
||||
save(config: Config): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the database-backed configuration. A legacy YAML file is imported
|
||||
* exactly once when the database has not yet been seeded, which makes the
|
||||
* migration safe for existing installations.
|
||||
*/
|
||||
export function loadConfig(store?: ConfigPersistence): Config {
|
||||
const storedConfig = store?.load(process.env);
|
||||
if (storedConfig) return storedConfig;
|
||||
|
||||
const configPath = path.resolve(process.cwd(), 'config.yaml');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
@@ -173,6 +186,11 @@ export function loadConfig(): Config {
|
||||
}
|
||||
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
|
||||
|
||||
if (!rawConfig.database) rawConfig.database = {};
|
||||
// The database must be located before application settings can be read, so
|
||||
// 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.WEB_DASHBOARD_TOKEN !== undefined) {
|
||||
if (!rawConfig.web) rawConfig.web = {};
|
||||
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
||||
@@ -195,5 +213,6 @@ export function loadConfig(): Config {
|
||||
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
|
||||
}
|
||||
|
||||
store?.save(result.data);
|
||||
return result.data;
|
||||
}
|
||||
+73
-64
@@ -1,74 +1,83 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import type { SubredditConfig } from './index.js';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { ConfigSchema, type Config } from './index.js';
|
||||
|
||||
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
|
||||
openrouter: Omit<Config['openrouter'], 'apiKey'>;
|
||||
web: Omit<Config['web'], 'authToken'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes an updated subreddit list back to `config.yaml`, preserving the
|
||||
* surrounding YAML structure, comment blocks, and per-row inline comments
|
||||
* for entries that survive the update.
|
||||
*
|
||||
* Strategy:
|
||||
* - Parse with `YAML.parseDocument` so the CST (including comments) is
|
||||
* intact.
|
||||
* - For each updated entry that matches an existing row by name, mutate
|
||||
* the existing node's fields in place — its `.comment` (the trailing
|
||||
* `# ...` annotation on the line) is preserved.
|
||||
* - New entries become fresh flow-style maps appended in the caller's
|
||||
* order; entries no longer in the list are dropped (and their comments
|
||||
* go with them, since the comment is attached to the row).
|
||||
* - Atomic write via tmp file + rename so a crash mid-write can't leave
|
||||
* a half-written config on disk.
|
||||
* 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 function persistSubreddits(
|
||||
subreddits: SubredditConfig[],
|
||||
configPath: string = path.resolve(process.cwd(), 'config.yaml'),
|
||||
): void {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const doc = YAML.parseDocument(raw);
|
||||
export class ConfigStore {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined;
|
||||
if (!seq || !YAML.isSeq(seq)) {
|
||||
throw new Error('reddit.subreddits is not a sequence in config.yaml');
|
||||
}
|
||||
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;
|
||||
|
||||
// Map existing items by their `name` so we can reuse YAMLMap nodes
|
||||
// (and the trailing comments attached to them).
|
||||
const existingByName = new Map<string, YAML.YAMLMap>();
|
||||
for (const item of seq.items) {
|
||||
if (!YAML.isMap(item)) continue;
|
||||
const name = item.get('name');
|
||||
if (typeof name === 'string') existingByName.set(name, item);
|
||||
}
|
||||
|
||||
const nextItems: YAML.YAMLMap[] = [];
|
||||
for (const sub of subreddits) {
|
||||
const existing = existingByName.get(sub.name);
|
||||
const node = existing ?? new YAML.YAMLMap();
|
||||
if (!existing) {
|
||||
// Match the on-disk style: flow-mapped, single-quoted strings.
|
||||
node.flow = true;
|
||||
node.set('name', sub.name);
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(row.config_json);
|
||||
} catch (error) {
|
||||
throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
|
||||
}
|
||||
setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
|
||||
setOrDelete(node, 'weight', sub.weight);
|
||||
setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
|
||||
setOrDelete(node, 'scripted', sub.scripted);
|
||||
nextItems.push(node);
|
||||
|
||||
return validateConfig(withBootstrapSettings(stored, environment));
|
||||
}
|
||||
|
||||
seq.items = nextItems;
|
||||
|
||||
const out = doc.toString();
|
||||
const tmp = configPath + '.tmp';
|
||||
fs.writeFileSync(tmp, out, 'utf-8');
|
||||
fs.renameSync(tmp, configPath);
|
||||
}
|
||||
|
||||
function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (node.has(key)) node.delete(key);
|
||||
return;
|
||||
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());
|
||||
}
|
||||
node.set(key, value);
|
||||
}
|
||||
|
||||
function toStoredConfig(config: Config): StoredConfig {
|
||||
const { database: _database, ...applicationConfig } = config;
|
||||
const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
|
||||
const { authToken: _authToken, ...web } = applicationConfig.web;
|
||||
return { ...applicationConfig, openrouter, web };
|
||||
}
|
||||
|
||||
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);
|
||||
return {
|
||||
...record,
|
||||
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
|
||||
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
|
||||
web: { ...web, authToken: environment.WEB_DASHBOARD_TOKEN },
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user