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

+18
View File
@@ -0,0 +1,18 @@
/**
* `fetch` with a hard client-side timeout. Node's global fetch otherwise
* inherits no timeout — a stalled connection will hang the caller forever,
* which has wedged the main loop more than once (incidents on 2026-05-20).
*
* On timeout, throws an error whose `.name === 'TimeoutError'`. Callers that
* want to differentiate timeout-vs-other failures can branch on that.
*/
export async function fetchWithTimeout(
input: string | URL | Request,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const { timeoutMs = 30_000, ...rest } = init;
return fetch(input, {
...rest,
signal: AbortSignal.timeout(timeoutMs),
});
}
+39
View File
@@ -0,0 +1,39 @@
import type { DetectedGender } from '../ai/openrouter.js';
/**
* Best-effort extraction of the poster's self-declared gender from a Reddit
* chat-sub post (title + selftext). Used by chat-only subreddits like
* /r/SextingRush where posts almost always carry a tag such as `[25F]`,
* `(M, 30)`, `F4M`, `25/f`, etc.
*
* Returns null when no tag is found — callers should skip those posts rather
* than guess.
*/
export function parseGenderTag(text: string): DetectedGender | null {
if (!text) return null;
const haystack = text.toLowerCase();
// X4Y tags (e.g. "f4m", "m4f", "f4mf"). The first letter is the poster.
const x4y = haystack.match(/\b([mf])4[mfa+]+\b/);
if (x4y) return x4y[1] === 'f' ? 'female' : 'male';
// Bracketed / parenthesised / bare tags with an age + gender letter in
// either order, optionally separated by a slash, space, or comma.
// Matches: [25F], (F25), 25f, f25, 25/f, f/25, [F, 25], (m 30), 25-f.
const ageGender =
haystack.match(/\[\s*(\d{2})\s*[\s,/\-]*\s*([mf])\s*\]/) ||
haystack.match(/\[\s*([mf])\s*[\s,/\-]*\s*(\d{2})\s*\]/) ||
haystack.match(/\(\s*(\d{2})\s*[\s,/\-]*\s*([mf])\s*\)/) ||
haystack.match(/\(\s*([mf])\s*[\s,/\-]*\s*(\d{2})\s*\)/) ||
haystack.match(/(?:^|[^a-z])(\d{2})\s*[\/\-]\s*([mf])(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])([mf])\s*[\/\-]\s*(\d{2})(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])(\d{2})([mf])(?:[^a-z]|$)/) ||
haystack.match(/(?:^|[^a-z])([mf])(\d{2})(?:[^a-z]|$)/);
if (ageGender) {
const letter = /[mf]/.test(ageGender[1]) ? ageGender[1] : ageGender[2];
return letter === 'f' ? 'female' : 'male';
}
return null;
}
+17 -1
View File
@@ -2,6 +2,22 @@ import winston from 'winston';
export type Logger = winston.Logger;
function safeStringify(value: unknown): string {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]';
seen.add(val);
}
if (typeof val === 'bigint') return val.toString();
return val;
});
} catch (err) {
return `[Unserializable: ${(err as Error).message}]`;
}
}
export function createLogger(): Logger {
return winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
@@ -15,7 +31,7 @@ export function createLogger(): Logger {
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
const metaStr = Object.keys(meta).length ? ` ${safeStringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
+9 -4
View File
@@ -2,17 +2,22 @@ import PQueue from 'p-queue';
import type { Config } from '../config/index.js';
import type { Logger } from './logger.js';
const DEFAULT_HOUR_WINDOW_MS = 3600000;
export class RateLimiter {
private queue: PQueue;
private lastMessageTime: number = 0;
private messagesThisHour: number = 0;
private hourStartTime: number = Date.now();
private readonly hourWindowMs: number;
constructor(
private config: Config,
private logger: Logger
private logger: Logger,
hourWindowMs: number = DEFAULT_HOUR_WINDOW_MS,
) {
this.queue = new PQueue({ concurrency: 1 });
this.hourWindowMs = hourWindowMs;
}
async throttle<T>(fn: () => Promise<T>, shouldCount: (result: T) => boolean = () => true): Promise<T> {
@@ -20,7 +25,7 @@ export class RateLimiter {
const now = Date.now();
// Reset hourly counter if needed
if (now - this.hourStartTime > 3600000) {
if (now - this.hourStartTime > this.hourWindowMs) {
this.messagesThisHour = 0;
this.hourStartTime = now;
this.logger.debug('Hourly rate limit counter reset');
@@ -28,7 +33,7 @@ export class RateLimiter {
// Check hourly limit
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
const waitTime = 3600000 - (now - this.hourStartTime);
const waitTime = this.hourWindowMs - (now - this.hourStartTime);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime);
this.messagesThisHour = 0;
@@ -94,7 +99,7 @@ export class RateLimiter {
}
// Calculate when the hourly counter resets
const hourlyResetAt = this.hourStartTime + 3600000;
const hourlyResetAt = this.hourStartTime + this.hourWindowMs;
// Check if hourly limit is reached
const isHourlyLimitReached = this.messagesThisHour >= maxMessages;
+18 -5
View File
@@ -7,16 +7,29 @@ export async function withRetry<T>(
retries?: number;
logger?: Logger;
operationName?: string;
/** Initial retry delay in ms. Set to 0 in tests to skip the backoff wait. */
minTimeout?: number;
maxTimeout?: number;
factor?: number;
randomize?: boolean;
} = {}
): Promise<T> {
const { retries = 3, logger, operationName = 'operation' } = options;
const {
retries = 3,
logger,
operationName = 'operation',
minTimeout = 5000,
maxTimeout = 60000,
factor = 2,
randomize = true,
} = options;
return pRetry(fn, {
retries,
minTimeout: 5000,
maxTimeout: 60000,
factor: 2,
randomize: true,
minTimeout,
maxTimeout,
factor,
randomize,
onFailedAttempt: (context) => {
const errorMessage = context.error instanceof Error ? context.error.message : String(context.error);
logger?.warn(