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

+75 -13
View File
@@ -2,25 +2,32 @@ import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { PostsRepository, MessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import { parseGenderTag } from '../utils/gender-tag.js';
export class MonitorService {
// Flips on each call so that when both pools have candidates the bot
// alternates between image and chat instead of always favouring whichever
// type the random shuffle put first.
private chatGoesFirst = false;
constructor(
private redditApi: RedditApiClient,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private config: Config,
private logger: Logger
private logger: Logger,
private delayBetweenFetchesMs: number = 1000,
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
// Create a shuffled copy of subreddits to check
const subreddits = [...this.config.reddit.subreddits];
this.shuffleArray(subreddits);
const subreddits = this.buildCheckOrder();
const targetGenders = this.config.reddit.targetGenders;
for (const subreddit of subreddits) {
try {
this.logger.debug(`Checking r/${subreddit} for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit);
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
// Filter for valid, unprocessed posts
const validPosts: RedditPost[] = [];
@@ -34,11 +41,28 @@ export class MonitorService {
continue;
}
// Chat subs almost always have a self-declared gender tag in the
// title/body (e.g. [25F], M4F). Drop posts that don't match the
// configured targetGenders here so they never enter the processor.
if (subreddit.type === 'chat' && targetGenders.length > 0) {
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
if (!tag) {
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
continue;
}
if (!targetGenders.includes(tag)) {
this.logger.debug(
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
);
continue;
}
}
validPosts.push(post);
}
if (validPosts.length > 0) {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit}`);
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`);
// Sort by creation time (newest first)
validPosts.sort((a, b) => b.created_utc - a.created_utc);
@@ -49,21 +73,59 @@ export class MonitorService {
return [validPosts[0]];
}
} catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error);
this.logger.error(`Failed to fetch posts from r/${subreddit.name}:`, error);
}
// Small delay between subreddit fetches to be nice to Reddit
await this.sleep(1000);
if (this.delayBetweenFetchesMs > 0) {
await this.sleep(this.delayBetweenFetchesMs);
}
}
return [];
}
private shuffleArray<T>(array: T[]): void {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
/**
* Build an interleaved check order that balances image and chat subs.
*
* Each pool is shuffled independently, then zipped together so a chat sub
* is checked roughly every other position even when there are far more
* image subs. `chatGoesFirst` flips per call so neither type is
* permanently advantaged when both pools have fresh posts.
*/
private buildCheckOrder(): Config['reddit']['subreddits'] {
const images = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'image'),
);
const chats = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'chat'),
);
const [a, b] = this.chatGoesFirst ? [chats, images] : [images, chats];
this.chatGoesFirst = !this.chatGoesFirst;
const out: Config['reddit']['subreddits'] = [];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
if (i < a.length) out.push(a[i]);
if (i < b.length) out.push(b[i]);
}
return out;
}
// Weighted random ordering (Efraimidis–Spirakis): key = random^(1/weight),
// sorted descending. Heavier-weighted subs are more likely to land earlier
// each cycle, while every sub still has a non-zero chance of any position.
private weightedShuffle<T extends { weight?: number }>(items: T[]): T[] {
return items
.map((item) => {
const w = item.weight && item.weight > 0 ? item.weight : 1;
const r = Math.random();
const key = r === 0 ? -Infinity : Math.log(r) / w;
return { item, key };
})
.sort((a, b) => b.key - a.key)
.map(({ item }) => item);
}
private sleep(ms: number): Promise<void> {