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
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
})
|
||||
),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user