Add durable candidate funnel
This commit is contained in:
1 parent
5abac31c28
commit
df35648920
20 files changed
+1128
-142
No files matched your search
@@ -7,6 +7,7 @@ export type {
|
||||
SyncPhase,
|
||||
SyncCompletion,
|
||||
SyncProgressListener,
|
||||
ConversationStartResult,
|
||||
} from './service.js';
|
||||
export type {
|
||||
ActiveChat,
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface UsernameResolver {
|
||||
resolveUsernameToT2?(username: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export type ConversationStartResult =
|
||||
| { status: 'sent'; chat: ActiveChat }
|
||||
| { status: 'terminal'; reason: string }
|
||||
| { status: 'retryable'; reason: string; retryAt: number };
|
||||
|
||||
interface BuildChatOpts {
|
||||
knownUsernames: Map<string, string>;
|
||||
includeTimelineMessages?: boolean;
|
||||
@@ -620,21 +625,37 @@ export class ChatService {
|
||||
message: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<ActiveChat | null> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||
const result = await this.startConversationDetailed(username, message, knownT2Id);
|
||||
return result.status === 'sent' ? result.chat : null;
|
||||
}
|
||||
|
||||
/** Start a DM while preserving terminal versus retryable failure semantics. */
|
||||
async startConversationDetailed(
|
||||
username: string,
|
||||
message: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<ConversationStartResult> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: 'Account authentication is temporarily unavailable',
|
||||
retryAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
this.logger.warn(
|
||||
`[chat] skipping startConversation(${username}) — invitation cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
||||
);
|
||||
return null;
|
||||
return { status: 'retryable', reason: active.reason, retryAt: active.until };
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const t2Id = await this.resolveUsernameToT2(username, knownT2Id);
|
||||
if (!t2Id) {
|
||||
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
|
||||
return null;
|
||||
return { status: 'terminal', reason: 'Reddit user no longer exists' };
|
||||
}
|
||||
const mxid = MatrixClient.t2ToMxid(t2Id);
|
||||
const { roomId, eventId } = await this.withAuth(() =>
|
||||
@@ -666,14 +687,30 @@ export class ChatService {
|
||||
const merged = [chat, ...existing.filter((c) => c.roomId !== roomId)];
|
||||
this.cache.set(merged);
|
||||
await this.cache.save(merged);
|
||||
return chat;
|
||||
return { status: 'sent', chat };
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixRateLimitError) {
|
||||
this.setInvitationCooldown(error);
|
||||
return null;
|
||||
const cooldown = this.getInvitationCooldown();
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: cooldown?.reason ?? 'Reddit temporarily rate-limited chat invitations',
|
||||
retryAt: cooldown?.until ?? Date.now() + error.retryAfterMs + 5_000,
|
||||
};
|
||||
}
|
||||
this.logger.error('[chat] startConversation failed:', error);
|
||||
return null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/do not accept chat requests/i.test(message)) {
|
||||
return { status: 'terminal', reason: 'Recipient does not accept chat requests' };
|
||||
}
|
||||
if (/unknown user|not found|M_NOT_FOUND/i.test(message)) {
|
||||
return { status: 'terminal', reason: 'Reddit user is unavailable' };
|
||||
}
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: 'Reddit chat invitation failed temporarily',
|
||||
retryAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+113
-23
@@ -16,11 +16,17 @@ interface ListingScreeningSummary {
|
||||
sellerSignals: number;
|
||||
missingGenderTag: number;
|
||||
genderNotTargeted: number;
|
||||
missingAgeTag: number;
|
||||
underage: number;
|
||||
eligible: number;
|
||||
outsideRecentWindow: number;
|
||||
deferred: number;
|
||||
}
|
||||
|
||||
export type CandidateScreeningResult =
|
||||
| { eligible: true }
|
||||
| { eligible: false; reason: string };
|
||||
|
||||
/** Coordinates permanent polling disablement across account runtimes. */
|
||||
export interface SubredditPollingControl {
|
||||
disableUnavailableSubreddit(subreddit: string): void;
|
||||
@@ -67,6 +73,8 @@ export class MonitorService {
|
||||
sellerSignals: 0,
|
||||
missingGenderTag: 0,
|
||||
genderNotTargeted: 0,
|
||||
missingAgeTag: 0,
|
||||
underage: 0,
|
||||
eligible: 0,
|
||||
outsideRecentWindow: 0,
|
||||
deferred: 0,
|
||||
@@ -93,6 +101,39 @@ export class MonitorService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chat subs almost always have a self-declared gender tag in the
|
||||
// title/body (e.g. [25F], M4F). Run this free check before seller
|
||||
// profile requests so obvious non-target posts consume no Reddit
|
||||
// request budget.
|
||||
if (subreddit.type === 'chat') {
|
||||
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
|
||||
if (targetGenders.length > 0 && !tag) {
|
||||
summary.missingGenderTag += 1;
|
||||
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
|
||||
this.recordRejection(post, subreddit.type, 'No gender tag in title/body');
|
||||
continue;
|
||||
}
|
||||
if (tag && targetGenders.length > 0 && !targetGenders.includes(tag)) {
|
||||
summary.genderNotTargeted += 1;
|
||||
this.logger.debug(
|
||||
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
|
||||
);
|
||||
this.recordRejection(post, subreddit.type, `Gender tag "${tag}" not targeted`);
|
||||
continue;
|
||||
}
|
||||
const age = parseSelfDeclaredAge(`${post.title}\n${post.selftext ?? ''}`);
|
||||
if (age === null) {
|
||||
summary.missingAgeTag += 1;
|
||||
this.recordRejection(post, subreddit.type, 'No adult age tag in title/body');
|
||||
continue;
|
||||
}
|
||||
if (age < 18) {
|
||||
summary.underage += 1;
|
||||
this.recordRejection(post, subreddit.type, `Self-declared age ${age} is under 18`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.reddit.filterSellers &&
|
||||
(this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
|
||||
@@ -111,27 +152,6 @@ export class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
// Chat subs almost always have a self-declared gender tag in the
|
||||
// title/body (e.g. [25F], M4F). Drop posts that don't match the
|
||||
// configured targetGenders here so they never enter the processor.
|
||||
if (subreddit.type === 'chat' && targetGenders.length > 0) {
|
||||
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
|
||||
if (!tag) {
|
||||
summary.missingGenderTag += 1;
|
||||
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
|
||||
this.recordRejection(post, subreddit.type, 'No gender tag in title/body');
|
||||
continue;
|
||||
}
|
||||
if (!targetGenders.includes(tag)) {
|
||||
summary.genderNotTargeted += 1;
|
||||
this.logger.debug(
|
||||
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
|
||||
);
|
||||
this.recordRejection(post, subreddit.type, `Gender tag "${tag}" not targeted`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point the candidate has passed every check. Select it now
|
||||
// rather than spending more Reddit requests screening users that
|
||||
// cannot be acted on during this poll cycle.
|
||||
@@ -186,6 +206,61 @@ export class MonitorService {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Fetch one listing without performing per-author screening. */
|
||||
async fetchCandidates(subreddit: Config['reddit']['subreddits'][number]): Promise<RedditPost[]> {
|
||||
if (!this.isSubredditEnabled(subreddit.name)) return [];
|
||||
try {
|
||||
this.logger.debug(`Collecting candidate posts from r/${subreddit.name} (${subreddit.type})...`);
|
||||
return await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
|
||||
} catch (error) {
|
||||
if (error instanceof RedditRateLimitError) throw error;
|
||||
if (error instanceof RedditSubredditUnavailableError) {
|
||||
this.pollingControl?.disableUnavailableSubreddit(subreddit.name);
|
||||
this.logger.warn(`Disabled r/${subreddit.name}: Reddit returned HTTP 404`);
|
||||
return [];
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to collect candidates from r/${subreddit.name}: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply cheap eligibility checks before any profile or AI work. */
|
||||
async screenCandidate(post: RedditPost, sourceType: 'image' | 'chat'): Promise<CandidateScreeningResult> {
|
||||
if (this.recipientHistory.hasSentTo(post.author)) {
|
||||
return { eligible: false, reason: 'User already contacted across accounts' };
|
||||
}
|
||||
|
||||
if (sourceType === 'chat') {
|
||||
const text = `${post.title}\n${post.selftext ?? ''}`;
|
||||
const tag = parseGenderTag(text);
|
||||
if (this.config.reddit.targetGenders.length > 0 && !tag) {
|
||||
return { eligible: false, reason: 'No gender tag in title/body' };
|
||||
}
|
||||
if (tag && this.config.reddit.targetGenders.length > 0 && !this.config.reddit.targetGenders.includes(tag)) {
|
||||
return { eligible: false, reason: `Gender tag "${tag}" not targeted` };
|
||||
}
|
||||
const age = parseSelfDeclaredAge(text);
|
||||
if (age === null) return { eligible: false, reason: 'No adult age tag in title/body' };
|
||||
if (age < 18) return { eligible: false, reason: `Self-declared age ${age} is under 18` };
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.reddit.filterSellers
|
||||
&& (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
|
||||
&& await this.redditApi.isSellerProfile(
|
||||
post.author,
|
||||
this.config.reddit.sellerKeywords,
|
||||
this.config.reddit.sellerDomains,
|
||||
post,
|
||||
)
|
||||
) {
|
||||
return { eligible: false, reason: 'Seller signals detected' };
|
||||
}
|
||||
|
||||
return { eligible: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an interleaved check order that balances image and chat subs.
|
||||
*
|
||||
@@ -252,6 +327,8 @@ export class MonitorService {
|
||||
`${summary.sellerSignals} seller signals`,
|
||||
`${summary.missingGenderTag} missing gender tag`,
|
||||
`${summary.genderNotTargeted} gender not targeted`,
|
||||
`${summary.missingAgeTag} missing age tag`,
|
||||
`${summary.underage} underage`,
|
||||
`${summary.eligible} eligible${selected ? ' (selected)' : ''}`,
|
||||
`${summary.outsideRecentWindow} outside recent window`,
|
||||
];
|
||||
@@ -262,12 +339,12 @@ export class MonitorService {
|
||||
}
|
||||
|
||||
/** Persist a completed screening decision so later polls do not repeat it. */
|
||||
private recordRejection(
|
||||
recordRejection(
|
||||
post: RedditPost,
|
||||
subredditType: 'image' | 'chat',
|
||||
reason: string,
|
||||
): void {
|
||||
this.postsRepo.insert({
|
||||
this.postsRepo.insertIfAbsent({
|
||||
id: post.id,
|
||||
subreddit: post.subreddit,
|
||||
author: post.author,
|
||||
@@ -280,3 +357,16 @@ export class MonitorService {
|
||||
this.postsRepo.setError(post.id, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse common adult-subreddit age tags without treating arbitrary numbers as ages. */
|
||||
export function parseSelfDeclaredAge(text: string): number | null {
|
||||
const patterns = [
|
||||
/(?:^|[\s[(])([1-9]\d?)\s*\[?(?:F|M|NB|TF|TM)(?:4[A-Z]+)?\b/i,
|
||||
/(?:^|[\s[(])(?:F|M|NB|TF|TM)\s*([1-9]\d?)\b/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return Number.parseInt(match[1], 10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export interface RecipientHistory {
|
||||
tryClaim?(recipient: string): boolean;
|
||||
commitClaim?(recipient: string): void;
|
||||
releaseClaim?(recipient: string): void;
|
||||
recoverStaleClaims?(cutoff: number): number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,10 @@ export class RecipientDeduplicator {
|
||||
this.reservations.delete(key);
|
||||
this.history.releaseClaim?.(recipient);
|
||||
}
|
||||
|
||||
recoverStaleReservations(cutoff: number): number {
|
||||
return this.history.recoverStaleClaims?.(cutoff) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecipient(recipient: string): string {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddi
|
||||
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
|
||||
import { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import { MonitorService } from './monitor.js';
|
||||
import { ProcessorService, isSkip } from './processor.js';
|
||||
import { ProcessorService, isSkip, type ProcessResult, type ProcessedPost } from './processor.js';
|
||||
import { ResponseCheckerService } from './response-checker.js';
|
||||
import { StrategySelector } from './strategy-selector.js';
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import {
|
||||
RedditAccountRuntime, RedditAccountRuntimeFactory,
|
||||
accountBrowserProfileDir, type RedditAccountRuntimeResources,
|
||||
type VerifiedAccountSession,
|
||||
type VerifiedAccountSession, type PreparedDeliveryResult,
|
||||
} from './reddit-account-runtime.js';
|
||||
import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
||||
import { ConfigStore } from '../config/persistence.js';
|
||||
@@ -291,80 +291,89 @@ class AccountRuntimeLoop {
|
||||
}
|
||||
|
||||
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);
|
||||
postsRepo.insert({
|
||||
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||
postUrl: redditApi.getPostUrl(post), imageUrl,
|
||||
postCreatedAt: post.created_utc * 1000, processedAt: Date.now(),
|
||||
const processed = await this.preparePost(post, signal);
|
||||
if (isSkip(processed)) return;
|
||||
await this.dispatchPreparedPost(processed, signal);
|
||||
}
|
||||
|
||||
async preparePost(post: RedditPost, signal?: AbortSignal): Promise<ProcessResult> {
|
||||
const { postsRepo, redditApi, processorService, responseChecker } = this.resources;
|
||||
if (signal?.aborted) throw new DOMException('Operation aborted', 'AbortError');
|
||||
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
||||
const isChat = subreddit?.type === 'chat';
|
||||
postsRepo.insertIfAbsent({
|
||||
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||
postUrl: redditApi.getPostUrl(post), imageUrl: isChat ? null : redditApi.getImageUrl(post),
|
||||
postCreatedAt: post.created_utc * 1_000, processedAt: Date.now(),
|
||||
});
|
||||
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,
|
||||
});
|
||||
// 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 });
|
||||
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({
|
||||
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,
|
||||
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.recipientDeduplicator.commit(post.author);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
recipient: post.author, imageUrl: imageUrl ?? undefined, postTitle: post.title, postUrl: redditApi.getPostUrl(post),
|
||||
});
|
||||
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);
|
||||
return processed;
|
||||
}
|
||||
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
|
||||
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService } = this.resources;
|
||||
const { post } = processed;
|
||||
if (signal?.aborted) {
|
||||
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
||||
}
|
||||
return rateLimiter.throttle(async (): Promise<PreparedDeliveryResult> => {
|
||||
if (this.config.browser.dryRun) {
|
||||
const reason = 'Dry run: message prepared but not sent';
|
||||
postsRepo.setError(post.id, reason);
|
||||
return { status: 'terminal', reason };
|
||||
}
|
||||
if (!this.recipientDeduplicator.tryReserve(post.author)) {
|
||||
const reason = 'User already contacted across accounts';
|
||||
postsRepo.setError(post.id, reason);
|
||||
return { status: 'terminal', reason };
|
||||
}
|
||||
|
||||
const start = await chatService.startConversationDetailed(
|
||||
post.author,
|
||||
processed.generatedMessage,
|
||||
post.author_fullname,
|
||||
);
|
||||
if (start.status !== 'sent') {
|
||||
this.recipientDeduplicator.release(post.author);
|
||||
if (start.status === 'terminal') {
|
||||
postsRepo.setError(post.id, start.reason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'skipped',
|
||||
message: `Skipped u/${post.author} (${start.reason})`, recipient: post.author,
|
||||
});
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
postsRepo.markMessageSent(post.id);
|
||||
messagesRepo.insert({
|
||||
postId: post.id, recipient: post.author, subject: post.author,
|
||||
message: processed.generatedMessage, sentAt: Date.now(),
|
||||
strategyId: processed.strategyId, chatRoomId: start.chat.roomId,
|
||||
experimentId: processed.experimentId, promptVersion: processed.promptVersion,
|
||||
modelId: processed.modelId, selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
this.recipientDeduplicator.commit(post.author);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
recipient: post.author,
|
||||
imageUrl: processed.sourceType === 'image' ? redditApi.getImageUrl(post) ?? undefined : undefined,
|
||||
postTitle: post.title, postUrl: redditApi.getPostUrl(post),
|
||||
});
|
||||
void chatService.refreshTopChats(5);
|
||||
return { status: 'sent', conversationId: start.chat.roomId! };
|
||||
}, (result) => result.status === 'sent', signal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { ProcessorService } from './processor.js';
|
||||
import type { ResponseCheckerService } from './response-checker.js';
|
||||
import type { RedditPost } from '../reddit/api.js';
|
||||
import type { ConversationAutomationService } from './conversation-automation.js';
|
||||
import type { ProcessedPost, ProcessResult } from './processor.js';
|
||||
|
||||
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
|
||||
export interface VerifiedAccountSession {
|
||||
@@ -85,8 +86,15 @@ export interface RedditAccountRuntimeLifecycle {
|
||||
stop(): void | Promise<void>;
|
||||
/** Process a post assigned by the shared discovery scheduler. */
|
||||
dispatchPost?(post: RedditPost, signal?: AbortSignal): Promise<void>;
|
||||
preparePost?(post: RedditPost, signal?: AbortSignal): Promise<ProcessResult>;
|
||||
dispatchPreparedPost?(post: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult>;
|
||||
}
|
||||
|
||||
export type PreparedDeliveryResult =
|
||||
| { status: 'sent'; conversationId: string }
|
||||
| { status: 'terminal'; reason: string }
|
||||
| { status: 'retryable'; reason: string; retryAt: number };
|
||||
|
||||
export type RedditAccountRuntimeState =
|
||||
| 'created'
|
||||
| 'starting'
|
||||
@@ -185,6 +193,26 @@ export class RedditAccountRuntime {
|
||||
return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null;
|
||||
}
|
||||
|
||||
async canScan(): Promise<boolean> {
|
||||
if (this.state !== 'running') return false;
|
||||
return this.resources.accountAuth.isFullyAuthenticated()
|
||||
|| await this.resources.accountAuth.ensureFullyAuthenticated();
|
||||
}
|
||||
|
||||
preparePost(post: RedditPost, signal?: AbortSignal): Promise<ProcessResult> {
|
||||
if (this.state !== 'running' || !this.lifecycle.preparePost) {
|
||||
throw new Error(`Runtime ${this.session.redditUsername} cannot prepare candidates`);
|
||||
}
|
||||
return this.lifecycle.preparePost(post, signal);
|
||||
}
|
||||
|
||||
dispatchPreparedPost(post: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
|
||||
if (this.state !== 'running' || !this.lifecycle.dispatchPreparedPost) {
|
||||
throw new Error(`Runtime ${this.session.redditUsername} cannot dispatch prepared candidates`);
|
||||
}
|
||||
return this.lifecycle.dispatchPreparedPost(post, signal);
|
||||
}
|
||||
|
||||
dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
|
||||
if (this.state !== 'running') {
|
||||
throw new Error(`Cannot assign a post to ${this.session.redditUsername}: runtime is not running`);
|
||||
|
||||
Reference in new issue
Block a user