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:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
+75
-13
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user