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
+49
-13
@@ -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(/&/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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user