feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

+33 -38
View File
@@ -24,6 +24,7 @@ import {
ChatService,
MatrixClient,
MatrixMediaDownloader,
MatrixUsernameResolver,
MatrixTokenRefresher,
loadCachedCredentials,
saveCredentials,
@@ -33,11 +34,11 @@ import {
import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
export interface AppContainer {
config: Config;
@@ -81,7 +82,11 @@ export interface AppContainer {
* Returns a populated container. Caller owns lifecycle (start loop, handle
* shutdown).
*/
export async function bootstrap(config: Config, logger: Logger): Promise<AppContainer> {
export async function bootstrap(
config: Config,
logger: Logger,
existingDatabase?: Database.Database,
): Promise<AppContainer> {
// Wire winston → dashboard log buffer immediately so the matrix-token
// sniff and other early bootstrap diagnostics are visible in /api/logs.
const broadcaster = new LogBroadcaster();
@@ -90,7 +95,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
}
// Database
const db = createDatabase(config);
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
@@ -186,35 +191,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// or token-sniff flow.
const tokenRefresher = new MatrixTokenRefresher();
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
// 4. Username resolver — Matrix profiles expose Reddit display names using
// the same authenticated session that powers chat sync.
const matrixUsernameResolver = new MatrixUsernameResolver(activeMatrixClient);
const usernameResolver: UsernameResolver = {
async resolveT2Ids(ids) {
if (ids.length === 0) return {};
const out: Record<string, string> = {};
for (let i = 0; i < ids.length; i += 100) {
const chunk = ids.slice(i, i + 100).join(',');
try {
const res = await fetchWithTimeout(
// www.reddit.com serves this unauthenticated; the api.reddit.com
// host 404s the same path without an OAuth token.
`https://www.reddit.com/api/user_data_by_account_ids.json?ids=${encodeURIComponent(chunk)}`,
{ headers: { 'User-Agent': config.reddit.userAgent }, timeoutMs: 15_000 },
);
if (!res.ok) {
logger.debug(`user_data_by_account_ids → ${res.status}`);
continue;
}
const json = (await res.json()) as Record<string, { name?: string }>;
for (const [t2, info] of Object.entries(json)) {
if (info?.name) out[t2] = info.name;
}
} catch (err) {
logger.debug('user_data_by_account_ids failed:', err);
}
}
return out;
},
resolveT2Ids: (ids) => matrixUsernameResolver.resolveT2Ids(ids),
resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
};
const chatService = new ChatService(
@@ -244,6 +226,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
setMatrixClient(client) {
activeMatrixClient = client;
chatService.setMatrixClient(client);
mediaDownloader.setMatrixClient(client);
matrixUsernameResolver.setMatrixClient(client);
},
},
(credentials) => new MatrixClient(credentials, logger),
@@ -268,7 +252,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// Stream sync-progress frames over the dashboard's SSE channel so the
// full-sync UI can show live phase + per-room progress.
let lastSyncProgress: { kind: 'full' | 'delta'; current: number; startedAt: number } | null = null;
chatService.setProgressListener((progress) => {
chatService.setProgressListener((progress, completion) => {
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
if (progress) {
const syncId = `chat-${progress.startedAt}`;
@@ -286,12 +270,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
}
lastSyncProgress = progress;
} else if (lastSyncProgress) {
broadcaster.dashboardEvents.sync({
phase: 'completed',
id: `chat-${lastSyncProgress.startedAt}`,
kind: lastSyncProgress.kind,
processed: lastSyncProgress.current,
});
const id = `chat-${lastSyncProgress.startedAt}`;
if (completion?.status === 'failed') {
broadcaster.dashboardEvents.sync({
phase: 'failed',
id,
kind: lastSyncProgress.kind,
error: completion.error,
});
} else {
broadcaster.dashboardEvents.sync({
phase: 'completed',
id,
kind: lastSyncProgress.kind,
processed: lastSyncProgress.current,
});
}
lastSyncProgress = null;
}
});
@@ -319,6 +313,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}