Improve messaging and dashboard reliability
This commit is contained in:
1 parent
345ec99348
commit
5a5a30a1d4
20 files changed
+406
-56
No files matched your search
+6
-1
@@ -12,6 +12,7 @@ import {
|
||||
ConversationStateRepository,
|
||||
ConversationOutcomesRepository,
|
||||
DeliveryOutboxRepository,
|
||||
RecipientHistoryRepository,
|
||||
} from './database/repositories.js';
|
||||
import { BrowserClient } from './browser/client.js';
|
||||
import { RedditApiClient } from './reddit/api.js';
|
||||
@@ -46,6 +47,7 @@ import { ChatCache } from './services/chat/cache.js';
|
||||
import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.js';
|
||||
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
|
||||
import { importLegacyAccountContent } from './database/legacy-content-import.js';
|
||||
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
||||
|
||||
export interface AppContainer {
|
||||
config: Config;
|
||||
@@ -63,6 +65,7 @@ export interface AppContainer {
|
||||
deliveryOutboxRepo: DeliveryOutboxRepository;
|
||||
conversationStateRepo: ConversationStateRepository;
|
||||
conversationOutcomesRepo: ConversationOutcomesRepository;
|
||||
recipientDeduplicator: RecipientDeduplicator;
|
||||
|
||||
redditApi: RedditApiClient;
|
||||
aiClient: OpenRouterClient;
|
||||
@@ -119,6 +122,7 @@ export async function bootstrap(
|
||||
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
|
||||
const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
|
||||
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
|
||||
const recipientDeduplicator = new RecipientDeduplicator(new RecipientHistoryRepository(db));
|
||||
const redditAccountsRepo = bootstrapAccountsRepo;
|
||||
const accountContentRepo = new AccountContentRepository(db);
|
||||
const legacyImport = importLegacyAccountContent(db);
|
||||
@@ -163,7 +167,7 @@ export async function bootstrap(
|
||||
|
||||
// AI + services
|
||||
const aiClient = new OpenRouterClient(config, logger);
|
||||
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
|
||||
const monitorService = new MonitorService(redditApi, postsRepo, recipientDeduplicator, config, logger);
|
||||
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
|
||||
const strategySelector = new StrategySelector(config, messagesRepo, logger);
|
||||
processorService.setStrategySelector(strategySelector);
|
||||
@@ -436,6 +440,7 @@ export async function bootstrap(
|
||||
deliveryOutboxRepo,
|
||||
conversationStateRepo,
|
||||
conversationOutcomesRepo,
|
||||
recipientDeduplicator,
|
||||
redditApi,
|
||||
aiClient,
|
||||
monitorService,
|
||||
|
||||
@@ -109,6 +109,24 @@ export interface DeliveryOutboxRecord {
|
||||
sentMessageId: number | null;
|
||||
}
|
||||
|
||||
/** Read-only recipient history shared by every Reddit account runtime. */
|
||||
export class RecipientHistoryRepository {
|
||||
private readonly hasSentToStmt: Database.Statement;
|
||||
|
||||
constructor(db: Database.Database) {
|
||||
this.hasSentToStmt = db.prepare(`
|
||||
SELECT 1
|
||||
FROM sent_messages
|
||||
WHERE recipient = ? COLLATE NOCASE
|
||||
LIMIT 1
|
||||
`);
|
||||
}
|
||||
|
||||
hasSentTo(recipient: string): boolean {
|
||||
return this.hasSentToStmt.get(recipient) !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable state for an outbound delivery. The caller must only perform the
|
||||
* remote send after claiming an item, then acknowledge it using the same
|
||||
|
||||
+26
-6
@@ -85,6 +85,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
accountAuth,
|
||||
postsRepo,
|
||||
messagesRepo,
|
||||
recipientDeduplicator,
|
||||
redditApi,
|
||||
monitorService,
|
||||
processorService,
|
||||
@@ -290,17 +291,35 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
|
||||
await rateLimiter.throttle(
|
||||
async () => {
|
||||
if (!recipientDeduplicator.tryReserve(post.author)) {
|
||||
const reason = 'User already contacted across accounts';
|
||||
logger.info(`Skipping u/${post.author} - ${reason.toLowerCase()}`);
|
||||
postsRepo.setError(post.id, reason);
|
||||
webServer?.logActivity(
|
||||
'skipped',
|
||||
`Skipped u/${post.author} (already contacted across accounts)`,
|
||||
post.author,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Sending message to u/${post.author}...`);
|
||||
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
|
||||
|
||||
const chat = await chatService.startConversation(
|
||||
post.author,
|
||||
processed.generatedMessage,
|
||||
post.author_fullname,
|
||||
);
|
||||
let chat;
|
||||
try {
|
||||
chat = await chatService.startConversation(
|
||||
post.author,
|
||||
processed.generatedMessage,
|
||||
post.author_fullname,
|
||||
);
|
||||
} catch (error) {
|
||||
recipientDeduplicator.release(post.author);
|
||||
throw error;
|
||||
}
|
||||
const success = chat !== null;
|
||||
|
||||
if (success) {
|
||||
if (chat) {
|
||||
postsRepo.markMessageSent(post.id);
|
||||
|
||||
// Refresh top chats in background so dashboard is up-to-date.
|
||||
@@ -340,6 +359,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
recipientDeduplicator.release(post.author);
|
||||
postsRepo.setError(post.id, 'Failed to send message');
|
||||
webServer?.logActivity(
|
||||
'error',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -197,6 +197,14 @@ export function createExpressSseHandler(
|
||||
}
|
||||
|
||||
try {
|
||||
// A first-time subscriber repairs authoritative dashboard snapshots on
|
||||
// open and only needs events published from this point onwards. Replaying
|
||||
// the entire buffer here can overflow the bounded response queue and
|
||||
// force EventSource into a reconnect loop. A genuine reconnect supplies
|
||||
// Last-Event-ID and still receives every buffered event after its cursor.
|
||||
const requestedLastEventId = request.get('Last-Event-ID');
|
||||
const replayCursor = requestedLastEventId
|
||||
?? options.hub.recent(1).at(-1)?.id;
|
||||
subscription = options.hub.subscribe(
|
||||
(event) => {
|
||||
if (!active) {
|
||||
@@ -208,7 +216,7 @@ export function createExpressSseHandler(
|
||||
close(true);
|
||||
}
|
||||
},
|
||||
{ lastEventId: request.get('Last-Event-ID') },
|
||||
{ lastEventId: replayCursor },
|
||||
);
|
||||
} catch {
|
||||
close(true);
|
||||
|
||||
Reference in new issue
Block a user