Restructure chat service, web routes, and add test suite

Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
This commit is contained in:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import fs from 'fs/promises';
import path from 'path';
import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile } from './types.js';
export interface SyncMarkers {
nextBatch: string | null;
lastFullSyncAt: number | null;
lastDeltaSyncAt: number | null;
}
/**
* In-memory + on-disk cache of Reddit chats plus the Matrix delta cursor
* so the bot can resume incremental sync across restarts.
*/
export class ChatCache {
private chats: ActiveChat[] = [];
private lastFetchTime = 0;
readonly cacheDurationMs = 60 * 1000;
private readonly filePath: string;
private nextBatch: string | null = null;
private lastFullSyncAt: number | null = null;
private lastDeltaSyncAt: number | null = null;
constructor(private logger: Logger, filePath?: string) {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
}
get(): ActiveChat[] {
return this.chats;
}
set(chats: ActiveChat[]): void {
this.chats = chats;
this.lastFetchTime = Date.now();
}
isFresh(): boolean {
return Date.now() - this.lastFetchTime < this.cacheDurationMs && this.chats.length > 0;
}
getMarkers(): SyncMarkers {
return {
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
};
}
setNextBatch(token: string | null): void {
this.nextBatch = token;
}
markFullSync(at: number = Date.now()): void {
this.lastFullSyncAt = at;
this.lastDeltaSyncAt = at;
}
markDeltaSync(at: number = Date.now()): void {
this.lastDeltaSyncAt = at;
}
async load(): Promise<void> {
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile;
if (!parsed || !Array.isArray(parsed.chats)) {
this.logger.warn('Chat cache file is invalid, ignoring.');
return;
}
this.chats = parsed.chats;
this.lastFetchTime = Date.now();
this.nextBatch = parsed.nextBatch ?? null;
this.lastFullSyncAt = parsed.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = parsed.lastDeltaSyncAt ?? null;
this.logger.info(
`Loaded ${parsed.chats.length} chats from cache` +
(this.lastFullSyncAt
? ` (last full sync ${new Date(this.lastFullSyncAt).toISOString()})`
: ' (no full sync recorded)'),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
this.logger.info('No chat cache found to load');
return;
}
this.logger.warn('Failed to load chat cache:', error);
}
}
async save(chats: ActiveChat[] = this.chats): Promise<void> {
const payload: ChatCacheFile = {
version: 1,
savedAt: Date.now(),
chats,
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
};
try {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Failed to save chat cache:', error);
}
}
}