Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.
Replace the boolean with an explicit three-way mode:
running — discover, screen, send, automate replies
scan — refresh chats, detect replies, check responses; send nothing
and discover nothing; actionable replies surface as backlog
stopped — everything halts, including background media sync
scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.
The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
551 lines
24 KiB
TypeScript
551 lines
24 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import path from 'node:path';
|
|
import type Database from 'better-sqlite3';
|
|
import type { Config } from '../config/index.js';
|
|
import type { Logger } from '../utils/logger.js';
|
|
import { BrowserClient } from '../browser/client.js';
|
|
import {
|
|
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
|
|
DeliveryOutboxRepository, ConversationStateRepository,
|
|
ConversationReadStateRepository,
|
|
ConversationAutomationClaimRepository,
|
|
ConversationOutcomesRepository, RedditUsersRepository,
|
|
ActivityLogRepository,
|
|
RecipientHistoryRepository,
|
|
} from '../database/repositories.js';
|
|
import {
|
|
AccountContentRepository,
|
|
RedditAccountEstablishmentRepository,
|
|
RedditAccountsRepository,
|
|
RedditRecipientAccountStatusRepository,
|
|
} from '../database/account-repositories.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, type ProcessResult, type ProcessedPost } from './processor.js';
|
|
import { ResponseCheckerService } from './response-checker.js';
|
|
import { StrategySelector } from './strategy-selector.js';
|
|
import {
|
|
AccountAuthContext,
|
|
normalizeRedditAccountId,
|
|
type AuthenticatedAccount,
|
|
type RedditAccountIdentity,
|
|
} from './account-auth-context.js';
|
|
import { ChatCache } from './chat/cache.js';
|
|
import {
|
|
BrowserMatrixTokenRefresher, ChatService, MatrixAuthError, MatrixClient,
|
|
MatrixMediaDownloader, MatrixUsernameResolver,
|
|
type MatrixCredentials, type UsernameResolver,
|
|
} from './chat/index.js';
|
|
import { MediaService } from './media.js';
|
|
import { MediaSyncService } from './media-sync.js';
|
|
import { RateLimiter } from '../utils/rate-limiter.js';
|
|
import {
|
|
RedditAccountRuntime, RedditAccountRuntimeFactory,
|
|
accountBrowserProfileDir, type RedditAccountRuntimeResources,
|
|
type VerifiedAccountSession, type PreparedDeliveryResult,
|
|
} from './reddit-account-runtime.js';
|
|
import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
|
import { ConfigStore } from '../config/persistence.js';
|
|
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
|
|
import { ConversationAutomationService } from './conversation-automation.js';
|
|
import { AiUsageRepository } from '../database/ai-usage-repository.js';
|
|
import { MediaLibraryRepository } from '../database/media-library-repository.js';
|
|
import { MediaLibraryExchangeService } from './media-library-exchange.js';
|
|
import { RecipientAccountStatusService } from './recipient-account-status.js';
|
|
import { AccountEstablishmentService } from './account-establishment.js';
|
|
import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js';
|
|
|
|
export interface ConcreteRuntimeFactoryOptions {
|
|
config: Config;
|
|
logger: Logger;
|
|
db: Database.Database;
|
|
getBotMode?: () => BotMode;
|
|
}
|
|
|
|
export function materializePreparedPostForAccount(
|
|
resources: Pick<RedditAccountRuntimeResources, 'postsRepo' | 'redditApi'>,
|
|
processed: ProcessedPost,
|
|
now: number = Date.now(),
|
|
): void {
|
|
const { post } = processed;
|
|
resources.postsRepo.insertIfAbsent({
|
|
id: post.id,
|
|
subreddit: post.subreddit,
|
|
author: post.author,
|
|
title: post.title,
|
|
postUrl: resources.redditApi.getPostUrl(post),
|
|
imageUrl: processed.sourceType === 'image' ? resources.redditApi.getImageUrl(post) : null,
|
|
postCreatedAt: post.created_utc * 1_000,
|
|
processedAt: now,
|
|
});
|
|
resources.postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
|
}
|
|
|
|
export function createConcreteRedditAccountRuntimeFactory(
|
|
options: ConcreteRuntimeFactoryOptions,
|
|
): RedditAccountRuntimeFactory {
|
|
const recipientDeduplicator = new RecipientDeduplicator(
|
|
new RecipientHistoryRepository(options.db),
|
|
);
|
|
const redditRequestCoordinator = new RedditRequestCoordinator();
|
|
return new RedditAccountRuntimeFactory(
|
|
(session) => buildRuntime(options, session, recipientDeduplicator, redditRequestCoordinator),
|
|
);
|
|
}
|
|
|
|
async function buildRuntime(
|
|
{ config, logger: rootLogger, db, getBotMode = () => 'running' }: ConcreteRuntimeFactoryOptions,
|
|
session: VerifiedAccountSession,
|
|
recipientDeduplicator: RecipientDeduplicator,
|
|
redditRequestCoordinator: RedditRequestCoordinator,
|
|
): Promise<RedditAccountRuntime> {
|
|
const logger = rootLogger.child({
|
|
accountId: session.accountId,
|
|
redditUsername: session.redditUsername,
|
|
});
|
|
const browserProfileDir = accountBrowserProfileDir(
|
|
path.resolve(process.cwd(), config.browser.sessionDir),
|
|
session.accountId,
|
|
);
|
|
const accountConfig: Config = {
|
|
...config,
|
|
browser: { ...config.browser, sessionDir: browserProfileDir },
|
|
web: { ...config.web, enabled: false },
|
|
};
|
|
|
|
const browserClient = new BrowserClient(accountConfig, logger);
|
|
await browserClient.initialize();
|
|
try {
|
|
await browserClient.importRedditCookies(session.cookies);
|
|
const identity = await browserClient.getAuthenticatedIdentity();
|
|
const expectedRedditId = matrixRedditId(session.matrixUserId);
|
|
if (!identity || !expectedRedditId || identity.accountId.toLowerCase().replace(/^t2_/, '') !== expectedRedditId) {
|
|
throw new Error(`Stored Reddit cookies no longer verify for u/${session.redditUsername}`);
|
|
}
|
|
|
|
const matrixClient = new MatrixClient(session.matrixCredentials, logger);
|
|
const accountsRepo = new RedditAccountsRepository(db);
|
|
const tokenRefresher = new BrowserMatrixTokenRefresher({
|
|
context: browserClient.getContext()!,
|
|
expectedUserId: session.matrixUserId,
|
|
verifyCredentials: (credentials) => new MatrixClient(credentials, logger).whoami(),
|
|
applyCredentials: (credentials) => matrixClient.updateCredentials(credentials),
|
|
persistCredentials: (credentials: MatrixCredentials) => {
|
|
session.matrixCredentials = credentials;
|
|
accountsRepo.updateMatrixCredentials(session.accountId, JSON.stringify(credentials));
|
|
},
|
|
logger,
|
|
});
|
|
const verifyMatrixSession = async (refreshOnAuthError: boolean): Promise<boolean> => {
|
|
try {
|
|
return await matrixClient.whoami() === session.matrixUserId;
|
|
} catch (error) {
|
|
if (!refreshOnAuthError || !(error instanceof MatrixAuthError)) throw error;
|
|
logger.warn(`Stored Matrix token for u/${session.redditUsername} was rejected — refreshing from the browser session`);
|
|
await tokenRefresher.refresh();
|
|
return await matrixClient.whoami() === session.matrixUserId;
|
|
}
|
|
};
|
|
if (!(await verifyMatrixSession(true))) {
|
|
throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`);
|
|
}
|
|
|
|
const accountAuth = new AccountAuthContext(logger);
|
|
accountAuth.markAuthenticated(runtimeAuthenticatedAccount(session, identity));
|
|
accountAuth.setAuthenticator(async () => {
|
|
const current = await browserClient.getAuthenticatedIdentity();
|
|
if (!current || matrixRedditId(session.matrixUserId) !== current.accountId.toLowerCase().replace(/^t2_/, '')) return null;
|
|
return await verifyMatrixSession(true)
|
|
? runtimeAuthenticatedAccount(session, current)
|
|
: null;
|
|
});
|
|
|
|
const postsRepo = new PostsRepository(db, session.accountId);
|
|
const messagesRepo = new MessagesRepository(db, session.accountId);
|
|
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
|
|
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
|
|
const conversationStateRepo = new ConversationStateRepository(db, session.accountId);
|
|
const conversationReadStateRepo = new ConversationReadStateRepository(db, session.accountId);
|
|
const conversationAutomationClaimRepo = new ConversationAutomationClaimRepository(db, session.accountId);
|
|
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
|
|
const redditUsersRepo = new RedditUsersRepository(db);
|
|
const accountContentRepo = new AccountContentRepository(db);
|
|
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
|
|
|
|
const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator);
|
|
redditApi.setBrowserContext(browserClient.getContext()!);
|
|
const chatPage = await browserClient.getContext()!.newPage();
|
|
const aiUsageRepo = new AiUsageRepository(db);
|
|
const aiClient = new OpenRouterClient(accountConfig, logger, undefined, (usage) => {
|
|
aiUsageRepo.record({ ...usage, accountId: session.accountId });
|
|
});
|
|
const monitorService = new MonitorService(
|
|
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
|
|
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
|
);
|
|
const processorService = new ProcessorService(
|
|
redditApi,
|
|
aiClient,
|
|
accountConfig,
|
|
logger,
|
|
undefined,
|
|
(sourceType) => messagesRepo
|
|
.getRecent(20)
|
|
.filter((message) => message.sourceType === sourceType || (sourceType === 'image' && !message.sourceType))
|
|
.map((message) => message.message),
|
|
);
|
|
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
|
|
processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies'));
|
|
|
|
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
|
|
const mediaService = new MediaService(
|
|
mediaDownloader,
|
|
logger,
|
|
undefined,
|
|
accountContentRepo,
|
|
() => session.accountId,
|
|
);
|
|
const usernameResolverImpl = new MatrixUsernameResolver(matrixClient);
|
|
const usernameResolver: UsernameResolver = {
|
|
resolveT2Ids: (ids) => usernameResolverImpl.resolveT2Ids(ids),
|
|
resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
|
|
};
|
|
const chatCache = new ChatCache(logger, undefined, accountContentRepo, session.accountId);
|
|
const chatService = new ChatService(
|
|
matrixClient, logger, successfulMessagesRepo, mediaService,
|
|
tokenRefresher, usernameResolver, chatCache,
|
|
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
|
|
);
|
|
await chatService.loadCache();
|
|
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
|
const recipientAccountStatusRepo = new RedditRecipientAccountStatusRepository(db);
|
|
const recipientAccountStatusService = new RecipientAccountStatusService(
|
|
chatService,
|
|
redditApi,
|
|
recipientAccountStatusRepo,
|
|
logger,
|
|
accountAuth,
|
|
);
|
|
const establishmentService = new AccountEstablishmentService(
|
|
session.accountId,
|
|
session.redditUsername,
|
|
redditApi,
|
|
new RedditAccountEstablishmentRepository(db),
|
|
accountConfig.accountEstablishment,
|
|
logger,
|
|
accountAuth,
|
|
);
|
|
const responseChecker = new ResponseCheckerService(
|
|
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
|
|
accountAuth, conversationOutcomesRepo,
|
|
);
|
|
const rateLimiter = new RateLimiter(accountConfig, logger, undefined, {
|
|
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
|
});
|
|
await rateLimiter.initialize();
|
|
const mediaExchange = new MediaLibraryExchangeService(
|
|
new MediaLibraryRepository(db),
|
|
chatService,
|
|
logger,
|
|
session.accountId,
|
|
undefined,
|
|
accountConfig.browser.dryRun,
|
|
);
|
|
const conversationAutomation = new ConversationAutomationService(
|
|
chatService,
|
|
aiClient,
|
|
conversationStateRepo,
|
|
messagesRepo,
|
|
conversationAutomationClaimRepo,
|
|
rateLimiter,
|
|
logger,
|
|
accountConfig.browser.dryRun,
|
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
|
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
|
|
() => accountConfig.reddit.subreddits,
|
|
Date.now,
|
|
undefined,
|
|
undefined,
|
|
mediaExchange,
|
|
// Read through to the live config object rather than snapshotting, so a
|
|
// dashboard configuration save applies to a running account without a
|
|
// restart. The service reads these on every inbound reply.
|
|
{
|
|
get offPlatformKill() { return accountConfig.conversations.offPlatformKill; },
|
|
get killOnSellerKeyword() { return accountConfig.conversations.killOnSellerKeyword; },
|
|
triage: conversationReadStateRepo,
|
|
screener: aiClient,
|
|
get screening() { return accountConfig.conversations.aiReplyScreening; },
|
|
},
|
|
);
|
|
|
|
const resources: RedditAccountRuntimeResources = {
|
|
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
|
|
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
|
|
conversationStateRepo, conversationReadStateRepo, conversationOutcomesRepo,
|
|
redditUsersRepo, accountContentRepo, recipientAccountStatusRepo,
|
|
activityLogRepo,
|
|
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
|
mediaSyncService, mediaExchange, rateLimiter,
|
|
recipientAccountStatusService, establishmentService,
|
|
};
|
|
const lifecycle = new AccountRuntimeLoop(
|
|
resources, accountConfig, logger, getBotMode, recipientDeduplicator,
|
|
);
|
|
return new RedditAccountRuntime(session, resources, lifecycle);
|
|
} catch (error) {
|
|
await browserClient.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Keeps the Reddit auth fullname separate from the Matrix-shaped runtime/database key. */
|
|
export function runtimeAuthenticatedAccount(
|
|
session: Pick<VerifiedAccountSession, 'matrixUserId'>,
|
|
identity: RedditAccountIdentity,
|
|
): AuthenticatedAccount {
|
|
return {
|
|
accountId: normalizeRedditAccountId(identity.accountId),
|
|
redditUsername: identity.username,
|
|
matrixUserId: session.matrixUserId,
|
|
};
|
|
}
|
|
|
|
/** Exported for tests; production code reaches it through the runtime factory. */
|
|
export class AccountRuntimeLoop {
|
|
private controller: AbortController | null = null;
|
|
private loop: Promise<void> | null = null;
|
|
private mediaSyncRunning = false;
|
|
private startupSyncStarted = false;
|
|
|
|
constructor(
|
|
private readonly resources: RedditAccountRuntimeResources,
|
|
private readonly config: Config,
|
|
private readonly logger: Logger,
|
|
private readonly getBotMode: () => BotMode,
|
|
private readonly recipientDeduplicator: RecipientDeduplicator,
|
|
) {}
|
|
|
|
start(): void {
|
|
if (this.loop) return;
|
|
this.controller = new AbortController();
|
|
this.resources.recipientAccountStatusService.start();
|
|
this.resources.establishmentService.start();
|
|
this.loop = this.run(this.controller.signal).finally(() => { this.loop = null; });
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.controller?.abort();
|
|
this.mediaSyncRunning = false;
|
|
this.resources.mediaSyncService.stop();
|
|
this.resources.recipientAccountStatusService.stop();
|
|
this.resources.establishmentService.stop();
|
|
await this.loop?.catch((error) => {
|
|
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
|
|
});
|
|
await this.resources.browserClient.close();
|
|
}
|
|
|
|
/**
|
|
* Starts or stops the background media sync only on a mode transition —
|
|
* `MediaSyncService.start()` re-wraps its own `stop()` on every call, so
|
|
* calling it once per loop tick would pile up closures.
|
|
*/
|
|
private reconcileMediaSync(enabled: boolean): void {
|
|
if (enabled === this.mediaSyncRunning) return;
|
|
this.mediaSyncRunning = enabled;
|
|
if (!enabled) {
|
|
this.resources.mediaSyncService.stop();
|
|
return;
|
|
}
|
|
this.resources.mediaSyncService.start();
|
|
if (this.startupSyncStarted) return;
|
|
this.startupSyncStarted = true;
|
|
void this.resources.chatService.runStartupSync()
|
|
.then(() => this.resources.chatService.enrichUsernames())
|
|
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
|
|
}
|
|
|
|
private async run(signal: AbortSignal): Promise<void> {
|
|
let nextReplyCheckAt = 0;
|
|
let nextPerformanceCheckAt = 0;
|
|
const replyInterval = 30_000;
|
|
const performanceInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000);
|
|
while (!signal.aborted) {
|
|
try {
|
|
const mode = this.getBotMode();
|
|
// Background media sync follows the mode too, so `stopped` really does
|
|
// halt every background refresh rather than leaving a 30-minute one.
|
|
this.reconcileMediaSync(allowsConversationSync(mode));
|
|
if (!allowsConversationSync(mode)) {
|
|
await delay(1_000, signal);
|
|
continue;
|
|
}
|
|
const auth = this.resources.accountAuth;
|
|
if (!auth.isFullyAuthenticated() && !(await auth.ensureFullyAuthenticated())) {
|
|
await delay(1_000, signal);
|
|
continue;
|
|
}
|
|
if (Date.now() >= nextReplyCheckAt) {
|
|
nextReplyCheckAt = Date.now() + replyInterval;
|
|
// In scan mode the same pass refreshes chats and counts actionable
|
|
// replies, but stops short of generating or sending any of them.
|
|
const automationResults = await this.resources.conversationAutomation.processPendingConversations(
|
|
signal,
|
|
{ scanOnly: !allowsSending(mode) },
|
|
);
|
|
for (const result of automationResults) {
|
|
if (result.kind === 'sent') continue;
|
|
this.resources.activityLogRepo.insert({
|
|
timestamp: Date.now(),
|
|
type: 'response',
|
|
message: result.kind === 'human-required'
|
|
? `Human follow-up required for u/${result.username}`
|
|
: result.kind === 'image-received'
|
|
? `Image received from u/${result.username}`
|
|
: `Automation stopped for u/${result.username} after a boundary`,
|
|
recipient: result.username,
|
|
});
|
|
}
|
|
}
|
|
if (Date.now() >= nextPerformanceCheckAt) {
|
|
nextPerformanceCheckAt = Date.now() + performanceInterval;
|
|
await this.resources.responseChecker.checkForResponses();
|
|
}
|
|
await delay(5_000, 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
|
|
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,
|
|
});
|
|
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, establishmentService } = this.resources;
|
|
const { post } = processed;
|
|
if (signal?.aborted) {
|
|
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
|
}
|
|
|
|
// Preparation may have been performed by a different authenticated
|
|
// scanner. Sent messages are account-scoped and reference that account's
|
|
// processed-post row, so materialise the prepared candidate locally before
|
|
// performing the irreversible remote chat creation.
|
|
materializePreparedPostForAccount(this.resources, processed);
|
|
|
|
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);
|
|
// The rejection reason is the only place Reddit tells us the account is
|
|
// too new to invite anyone; the code, when there is one, rides on the
|
|
// cooldown the chat service just recorded.
|
|
establishmentService.recordSendFailure(
|
|
start.reason,
|
|
chatService.getInvitationCooldown()?.redditCode ?? null,
|
|
);
|
|
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);
|
|
establishmentService.recordSendSuccess();
|
|
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);
|
|
}
|
|
}
|
|
|
|
function matrixRedditId(matrixUserId: string): string | null {
|
|
return matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i)?.[1]?.toLowerCase() ?? null;
|
|
}
|
|
|
|
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
if (signal.aborted) return Promise.reject(new DOMException('Operation aborted', 'AbortError'));
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener('abort', onAbort);
|
|
reject(new DOMException('Operation aborted', 'AbortError'));
|
|
};
|
|
const timer = setTimeout(() => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|