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

+11
View File
@@ -57,12 +57,23 @@ export function runMigrations(db: Database.Database): void {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
recipient TEXT,
image_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_posts_subreddit
ON processed_posts(subreddit);
CREATE INDEX IF NOT EXISTS idx_posts_processed_at
ON processed_posts(processed_at);
CREATE INDEX IF NOT EXISTS idx_posts_message_sent
ON processed_posts(message_sent);
CREATE INDEX IF NOT EXISTS idx_activity_log_timestamp
ON activity_log(timestamp);
`);
// Add new columns to existing tables (safe to run multiple times)
+96 -2
View File
@@ -26,6 +26,7 @@ export interface SentMessageRecord {
subreddit?: string;
postTitle?: string;
postImageUrl?: string;
postImageAnalysis?: string;
}
export interface SuccessfulMessageRecord {
@@ -60,6 +61,80 @@ export interface DailyTrend {
responseRate: number;
}
export interface ActivityLogRecord {
timestamp: number;
type: string;
message: string;
recipient: string | null;
imageUrl: string | null;
}
export class ActivityLogRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private pruneStmt: Database.Statement;
private countSentSkippedStmt: Database.Statement;
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
INSERT INTO activity_log (timestamp, type, message, recipient, image_url)
VALUES (?, ?, ?, ?, ?)
`);
this.getRecentStmt = db.prepare(`
SELECT timestamp, type, message, recipient, image_url
FROM activity_log
ORDER BY id DESC
LIMIT ?
`);
this.pruneStmt = db.prepare(`
DELETE FROM activity_log
WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT ?)
`);
this.countSentSkippedStmt = db.prepare(`
SELECT COUNT(*) AS count FROM activity_log WHERE type IN ('sent', 'skipped')
`);
}
insert(item: {
timestamp: number;
type: string;
message: string;
recipient?: string;
imageUrl?: string;
}): void {
this.insertStmt.run(
item.timestamp,
item.type,
item.message,
item.recipient ?? null,
item.imageUrl ?? null,
);
}
getRecent(limit: number): ActivityLogRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => ({
timestamp: row.timestamp as number,
type: row.type as string,
message: row.message as string,
recipient: (row.recipient as string) ?? null,
imageUrl: (row.image_url as string) ?? null,
}));
}
prune(keep: number): void {
this.pruneStmt.run(keep);
}
countSentSkipped(): number {
const row = this.countSentSkippedStmt.get() as { count: number };
return row.count;
}
}
export class PostsRepository {
private insertStmt: Database.Statement;
private existsStmt: Database.Statement;
@@ -166,6 +241,7 @@ export class PostsRepository {
export class MessagesRepository {
private insertStmt: Database.Statement;
private getByPostIdStmt: Database.Statement;
private getLatestByRecipientStmt: Database.Statement;
private getRecentStmt: Database.Statement;
private getUncheckedStmt: Database.Statement;
private markResponseStmt: Database.Statement;
@@ -180,8 +256,19 @@ export class MessagesRepository {
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
this.getLatestByRecipientStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
WHERE sm.recipient = ? COLLATE NOCASE
ORDER BY sm.sent_at DESC
LIMIT 1
`);
this.getRecentStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
ORDER BY sm.sent_at DESC
@@ -235,6 +322,12 @@ export class MessagesRepository {
return this.mapRow(row);
}
getLatestByRecipient(recipient: string): SentMessageRecord | undefined {
const row = this.getLatestByRecipientStmt.get(recipient) as Record<string, unknown> | undefined;
if (!row) return undefined;
return this.mapRow(row);
}
getRecent(limit: number = 10): SentMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row));
@@ -340,6 +433,7 @@ export class MessagesRepository {
subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title as string) || undefined,
postImageUrl: (row.post_image_url as string) || undefined,
postImageAnalysis: (row.post_image_analysis as string) || undefined,
};
}
}
@@ -358,7 +452,7 @@ export class SuccessfulMessagesRepository {
`);
this.getRecentStmt = db.prepare(`
SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ?
SELECT * FROM successful_messages ORDER BY created_at DESC, id DESC LIMIT ?
`);
// Get examples prioritizing same subreddit, then falling back to others