Improve messaging and dashboard reliability

This commit is contained in:
jjbrunton committed 2026-08-08 21:09:20 +01:00
1 parent 345ec99348
commit 5a5a30a1d4
20 files changed
+406 -56

No files matched your search

+27
View File
@@ -14,6 +14,18 @@ export class MatrixAuthError extends Error {
}
}
/**
* Thrown when Reddit's homeserver explicitly disables a Matrix endpoint.
* Callers can remember the missing capability instead of retrying it for
* every room while still using the remaining Matrix API normally.
*/
export class MatrixFeatureDisabledError extends Error {
constructor(message: string) {
super(message);
this.name = 'MatrixFeatureDisabledError';
}
}
/**
* Thrown when Reddit's homeserver returns a 429 with a `retry_after_ms`
* longer than we're willing to block on. Caller (typically ChatService)
@@ -201,6 +213,11 @@ export class MatrixClient {
`Matrix ${method} ${path} → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
);
}
if (res.status === 403 && isFeatureDisabledFailure(failure.text)) {
throw new MatrixFeatureDisabledError(
`Matrix ${method} ${path} → 403 (disabled by configuration)`,
);
}
throw new Error(`Matrix ${method} ${path} → ${res.status}: ${failure.text.slice(0, 300)}`);
}
@@ -626,6 +643,16 @@ function isMatrixAuthenticationFailure(status: number, errcode?: string): boolea
return status === 401 || errcode === 'M_UNKNOWN_TOKEN' || errcode === 'M_MISSING_TOKEN';
}
function isFeatureDisabledFailure(responseText: string): boolean {
try {
const parsed = JSON.parse(responseText) as { message?: unknown };
return typeof parsed.message === 'string'
&& parsed.message.toLowerCase() === 'disabled by configuration';
} catch {
return false;
}
}
function parseMxc(mxc: string): { serverName: string; mediaId: string } | null {
const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!m) return null;
+32 -19
View File
@@ -7,7 +7,12 @@ import type {
} from '../../database/repositories.js';
import type { MediaService } from '../media.js';
import { ChatCache } from './cache.js';
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
import {
MatrixClient,
MatrixAuthError,
MatrixFeatureDisabledError,
MatrixRateLimitError,
} from './matrix/client.js';
import type { MatrixJoinedRoom, MatrixTimelineEvent } from './matrix/types.js';
import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown } from './types.js';
@@ -128,6 +133,7 @@ export class ChatService {
private readonly t2ToUsername = new Map<string, string>();
/** Rooms whose /joined_members lookup has already failed — don't retry. */
private readonly unresolvableRooms = new Set<string>();
private roomStateLookupAvailable = true;
private syncingKind: 'full' | 'delta' | null = null;
private progress: SyncProgress | null = null;
private progressListener: SyncProgressListener | null = null;
@@ -1341,25 +1347,32 @@ export class ChatService {
chat.username.startsWith('@');
if (!needsLookup) continue;
let t2: string | undefined;
try {
const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!));
const otherMember = state.find((e) => {
if (e.type !== 'm.room.member') return false;
const sk = (e as { state_key?: string }).state_key;
const membership = (e.content as { membership?: string }).membership;
return (
sk &&
sk !== this.matrix.userId &&
(membership === 'join' || membership === 'invite')
);
}) as { state_key?: string } | undefined;
t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1];
} catch (err) {
if (err instanceof MatrixAuthenticationUnavailableError) {
this.logger.warn('[chat] username enrichment paused until Reddit login is restored');
return;
if (this.roomStateLookupAvailable) {
try {
const state = await this.withAuth(() => this.matrix.getRoomFullState(chat.roomId!));
const otherMember = state.find((e) => {
if (e.type !== 'm.room.member') return false;
const sk = (e as { state_key?: string }).state_key;
const membership = (e.content as { membership?: string }).membership;
return (
sk &&
sk !== this.matrix.userId &&
(membership === 'join' || membership === 'invite')
);
}) as { state_key?: string } | undefined;
t2 = otherMember?.state_key?.match(/^@(t2_[^:]+):/)?.[1];
} catch (err) {
if (err instanceof MatrixAuthenticationUnavailableError) {
this.logger.warn('[chat] username enrichment paused until Reddit login is restored');
return;
}
if (err instanceof MatrixFeatureDisabledError) {
this.roomStateLookupAvailable = false;
this.logger.debug('[chat] room state lookup is disabled upstream; using message history instead');
} else {
this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err);
}
}
this.logger.debug(`[chat] /state lookup failed for ${chat.roomId}:`, err);
}
if (!t2) {
try {
+3 -3
View File
@@ -1,5 +1,5 @@
import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../reddit/api.js';
import type { PostsRepository, MessagesRepository } from '../database/repositories.js';
import type { PostsRepository } 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';
@@ -13,7 +13,7 @@ export class MonitorService {
constructor(
private redditApi: RedditApiClient,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private recipientHistory: { hasSentTo(recipient: string): boolean },
private config: Config,
private logger: Logger,
private delayBetweenFetchesMs: number = 1000,
@@ -46,7 +46,7 @@ export class MonitorService {
continue;
}
if (this.messagesRepo.hasSentTo(post.author)) {
if (this.recipientHistory.hasSentTo(post.author)) {
this.logger.debug(`Skipping post ${post.id} from u/${post.author} - already messaged this user`);
continue;
}
+39
View File
@@ -0,0 +1,39 @@
export interface RecipientHistory {
hasSentTo(recipient: string): boolean;
}
/**
* Coordinates recipient selection across account runtimes.
*
* SQLite history prevents duplicates across restarts. The in-memory
* reservation closes the smaller race where two live runtimes both pass the
* history check before either has persisted its successful send.
*/
export class RecipientDeduplicator {
private readonly reservations = new Set<string>();
constructor(private readonly history: RecipientHistory) {}
hasSentTo(recipient: string): boolean {
const key = normalizeRecipient(recipient);
return !key || this.reservations.has(key) || this.history.hasSentTo(recipient);
}
tryReserve(recipient: string): boolean {
const key = normalizeRecipient(recipient);
if (!key || this.reservations.has(key) || this.history.hasSentTo(recipient)) {
return false;
}
this.reservations.add(key);
return true;
}
release(recipient: string): void {
const key = normalizeRecipient(recipient);
if (key) this.reservations.delete(key);
}
}
function normalizeRecipient(recipient: string): string {
return recipient.trim().toLocaleLowerCase('en-US');
}
+36 -5
View File
@@ -9,6 +9,7 @@ import {
DeliveryOutboxRepository, ConversationStateRepository,
ConversationOutcomesRepository, RedditUsersRepository,
ActivityLogRepository,
RecipientHistoryRepository,
} from '../database/repositories.js';
import { AccountContentRepository } from '../database/account-repositories.js';
import { RedditApiClient } from '../reddit/api.js';
@@ -36,6 +37,7 @@ import {
accountBrowserProfileDir, type RedditAccountRuntimeResources,
type VerifiedAccountSession,
} from './reddit-account-runtime.js';
import { RecipientDeduplicator } from './recipient-deduplicator.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -47,12 +49,18 @@ export interface ConcreteRuntimeFactoryOptions {
export function createConcreteRedditAccountRuntimeFactory(
options: ConcreteRuntimeFactoryOptions,
): RedditAccountRuntimeFactory {
return new RedditAccountRuntimeFactory((session) => buildRuntime(options, session));
const recipientDeduplicator = new RecipientDeduplicator(
new RecipientHistoryRepository(options.db),
);
return new RedditAccountRuntimeFactory(
(session) => buildRuntime(options, session, recipientDeduplicator),
);
}
async function buildRuntime(
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
session: VerifiedAccountSession,
recipientDeduplicator: RecipientDeduplicator,
): Promise<RedditAccountRuntime> {
const browserProfileDir = accountBrowserProfileDir(
path.resolve(process.cwd(), config.browser.sessionDir),
@@ -103,7 +111,9 @@ async function buildRuntime(
redditApi.setBrowserContext(browserClient.getContext()!);
const chatPage = await browserClient.getContext()!.newPage();
const aiClient = new OpenRouterClient(accountConfig, logger);
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, accountConfig, logger);
const monitorService = new MonitorService(
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
);
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies'));
@@ -146,7 +156,9 @@ async function buildRuntime(
monitorService, processorService, responseChecker, chatService, mediaService,
mediaSyncService, rateLimiter,
};
const lifecycle = new AccountRuntimeLoop(resources, accountConfig, logger, isBotRunning);
const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
);
return new RedditAccountRuntime(session, resources, lifecycle);
} catch (error) {
await browserClient.close();
@@ -175,6 +187,7 @@ class AccountRuntimeLoop {
private readonly config: Config,
private readonly logger: Logger,
private readonly isBotRunning: () => boolean,
private readonly recipientDeduplicator: RecipientDeduplicator,
) {}
start(): void {
@@ -257,8 +270,26 @@ class AccountRuntimeLoop {
}
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
await rateLimiter.throttle(async () => {
const chat = await chatService.startConversation(post.author, processed.generatedMessage, post.author_fullname);
if (!chat) return false;
if (!this.recipientDeduplicator.tryReserve(post.author)) {
postsRepo.setError(post.id, 'User already contacted across accounts');
this.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'skipped',
message: `Skipped u/${post.author} (already contacted across accounts)`,
recipient: post.author,
});
return false;
}
let chat;
try {
chat = await chatService.startConversation(post.author, processed.generatedMessage, post.author_fullname);
} catch (error) {
this.recipientDeduplicator.release(post.author);
throw error;
}
if (!chat) {
this.recipientDeduplicator.release(post.author);
return false;
}
postsRepo.markMessageSent(post.id);
messagesRepo.insert({
postId: post.id, recipient: post.author, subject: post.author,