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

+49 -13
View File
@@ -13,6 +13,7 @@ export interface RedditPost {
permalink: string;
thumbnail: string;
is_self: boolean;
selftext?: string;
post_hint?: string;
preview?: {
images: Array<{
@@ -25,6 +26,25 @@ export interface RedditPost {
spoiler: boolean;
}
// Extracts URL-ish tokens (with or without scheme) and returns the first
// domain match. Matches `host` or any subdomain (`*.host`), case-insensitive.
export function findSellerDomain(text: string, domains: string[]): string | null {
if (!text || domains.length === 0) return null;
const normalized = text.toLowerCase();
const urlRegex = /(?:https?:\/\/|(?<![\w.@])(?=[a-z0-9-]+\.[a-z]))([a-z0-9.-]+\.[a-z]{2,})(?:[/?#][^\s)]*)?/gi;
const hosts: string[] = [];
for (const m of normalized.matchAll(urlRegex)) {
hosts.push(m[1].replace(/\.+$/, ''));
}
for (const host of hosts) {
for (const domain of domains) {
const d = domain.toLowerCase();
if (host === d || host.endsWith('.' + d)) return domain;
}
}
return null;
}
export class RedditApiClient {
private client: AxiosInstance;
private browserContext: BrowserContext | null = null;
@@ -62,7 +82,7 @@ export class RedditApiClient {
}
}
async getNewPosts(subreddit: string, limit = 25): Promise<RedditPost[]> {
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
@@ -77,9 +97,9 @@ export class RedditApiClient {
const posts: RedditPost[] = response.data.data.children
.map((child: { data: RedditPost }) => child.data)
.filter((post: RedditPost) => this.isValidPost(post));
.filter((post: RedditPost) => this.isValidPost(post, type));
this.logger.debug(`Fetched ${posts.length} valid image posts from r/${subreddit}`);
this.logger.debug(`Fetched ${posts.length} valid ${type} posts from r/${subreddit}`);
return posts;
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -101,17 +121,19 @@ export class RedditApiClient {
}
}
private isValidPost(post: RedditPost): boolean {
// Skip self posts (text only)
if (post.is_self) return false;
private isValidPost(post: RedditPost, type: 'image' | 'chat' = 'image'): boolean {
// Skip deleted/removed authors
if (post.author === '[deleted]' || post.author === 'AutoModerator') return false;
// Skip NSFW if we want to be safe (configurable later)
// if (post.over_18) return false;
if (type === 'chat') {
// Chat subs are text-driven. Require a title (always present) and
// ideally some self text — but allow title-only since some tags live
// in the title alone (e.g. "[25F] up late, dm me").
return true;
}
// Check if it's an image post
// image type:
if (post.is_self) return false;
return this.hasImage(post);
}
@@ -180,8 +202,12 @@ export class RedditApiClient {
return url.replace(/&amp;/g, '&');
}
async isSellerProfile(username: string, sellerKeywords: string[]): Promise<boolean> {
if (sellerKeywords.length === 0) return false;
async isSellerProfile(
username: string,
sellerKeywords: string[],
sellerDomains: string[] = []
): Promise<boolean> {
if (sellerKeywords.length === 0 && sellerDomains.length === 0) return false;
try {
const cookieHeader = await this.getCookieHeader();
@@ -210,7 +236,17 @@ export class RedditApiClient {
for (const keyword of sellerKeywords) {
if (textToCheck.includes(keyword.toLowerCase())) {
this.logger.info(
`u/${username} flagged as seller profile (matched: "${keyword}")`
`u/${username} flagged as seller profile (matched keyword: "${keyword}")`
);
return true;
}
}
if (sellerDomains.length > 0) {
const matchedDomain = findSellerDomain(textToCheck, sellerDomains);
if (matchedDomain) {
this.logger.info(
`u/${username} flagged as seller profile (matched domain: "${matchedDomain}")`
);
return true;
}