feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
@@ -12,7 +12,8 @@ import {
|
||||
RecipientHistoryRepository,
|
||||
} from '../database/repositories.js';
|
||||
import { AccountContentRepository } from '../database/account-repositories.js';
|
||||
import { RedditApiClient } from '../reddit/api.js';
|
||||
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
|
||||
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
|
||||
import { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import { MonitorService } from './monitor.js';
|
||||
import { ProcessorService, isSkip } from './processor.js';
|
||||
@@ -52,8 +53,9 @@ export function createConcreteRedditAccountRuntimeFactory(
|
||||
const recipientDeduplicator = new RecipientDeduplicator(
|
||||
new RecipientHistoryRepository(options.db),
|
||||
);
|
||||
const redditRequestCoordinator = new RedditRequestCoordinator();
|
||||
return new RedditAccountRuntimeFactory(
|
||||
(session) => buildRuntime(options, session, recipientDeduplicator),
|
||||
(session) => buildRuntime(options, session, recipientDeduplicator, redditRequestCoordinator),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ async function buildRuntime(
|
||||
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
|
||||
session: VerifiedAccountSession,
|
||||
recipientDeduplicator: RecipientDeduplicator,
|
||||
redditRequestCoordinator: RedditRequestCoordinator,
|
||||
): Promise<RedditAccountRuntime> {
|
||||
const browserProfileDir = accountBrowserProfileDir(
|
||||
path.resolve(process.cwd(), config.browser.sessionDir),
|
||||
@@ -107,7 +110,7 @@ async function buildRuntime(
|
||||
const accountContentRepo = new AccountContentRepository(db);
|
||||
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
|
||||
|
||||
const redditApi = new RedditApiClient(accountConfig, logger);
|
||||
const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator);
|
||||
redditApi.setBrowserContext(browserClient.getContext()!);
|
||||
const chatPage = await browserClient.getContext()!.newPage();
|
||||
const aiClient = new OpenRouterClient(accountConfig, logger);
|
||||
@@ -135,7 +138,7 @@ async function buildRuntime(
|
||||
const chatService = new ChatService(
|
||||
matrixClient, logger, successfulMessagesRepo, mediaService,
|
||||
new MatrixTokenRefresher(), usernameResolver, chatCache,
|
||||
messagesRepo, redditUsersRepo, accountAuth,
|
||||
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
|
||||
);
|
||||
await chatService.loadCache();
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
||||
@@ -227,23 +230,23 @@ class AccountRuntimeLoop {
|
||||
nextResponseCheckAt = Date.now() + responseInterval;
|
||||
await this.resources.responseChecker.checkForResponses();
|
||||
}
|
||||
if (!this.resources.chatService.getInvitationCooldown()) {
|
||||
await this.processPosts(signal);
|
||||
}
|
||||
await delay(this.config.reddit.pollIntervalMs, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
if (error instanceof RedditRateLimitError) {
|
||||
await delay(error.retryAfterMs + 1_000, signal);
|
||||
continue;
|
||||
}
|
||||
this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error);
|
||||
await delay(60_000, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processPosts(signal: AbortSignal): Promise<void> {
|
||||
const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
|
||||
const posts = await monitorService.getUnprocessedPosts();
|
||||
for (const post of posts) {
|
||||
if (signal.aborted) return;
|
||||
async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
|
||||
const { postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
||||
const isChat = subreddit?.type === 'chat';
|
||||
const imageUrl = isChat ? null : redditApi.getImageUrl(post);
|
||||
@@ -251,25 +254,26 @@ class AccountRuntimeLoop {
|
||||
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||
postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
if (
|
||||
this.config.reddit.filterSellers
|
||||
&& (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
|
||||
&& await redditApi.isSellerProfile(post.author, this.config.reddit.sellerKeywords, this.config.reddit.sellerDomains)
|
||||
) {
|
||||
postsRepo.setError(post.id, 'Seller profile detected');
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (seller profile)`, recipient: post.author });
|
||||
continue;
|
||||
}
|
||||
// Seller screening is performed once by the shared MonitorService before
|
||||
// assignment. Do not repeat its profile request for every recipient.
|
||||
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
|
||||
const processed = await processorService.processPost(post);
|
||||
if (isSkip(processed)) {
|
||||
postsRepo.setError(post.id, processed.skipReason);
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author });
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||
await rateLimiter.throttle(async () => {
|
||||
if (this.config.browser.dryRun) {
|
||||
const reason = 'Dry run: message prepared but not sent';
|
||||
postsRepo.setError(post.id, reason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `${reason} to u/${post.author}`, recipient: post.author,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!this.recipientDeduplicator.tryReserve(post.author)) {
|
||||
postsRepo.setError(post.id, 'User already contacted across accounts');
|
||||
this.resources.activityLogRepo.insert({
|
||||
@@ -295,6 +299,9 @@ class AccountRuntimeLoop {
|
||||
postId: post.id, recipient: post.author, subject: post.author,
|
||||
message: processed.generatedMessage, sentAt: Date.now(),
|
||||
strategyId: processed.strategyId, chatRoomId: chat.roomId,
|
||||
experimentId: processed.experimentId, promptVersion: processed.promptVersion,
|
||||
modelId: processed.modelId, selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
@@ -303,12 +310,11 @@ class AccountRuntimeLoop {
|
||||
void chatService.refreshTopChats(5);
|
||||
return true;
|
||||
}, (success) => success, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
|
||||
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
|
||||
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
|
||||
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user