Harden conversation replies and prioritize newest candidates

This commit is contained in:
jjbrunton committed 2026-08-09 18:16:57 +01:00
1 parent f98c5c732d
commit b8e4a1bb88
23 files changed
+598 -113

No files matched your search

+12 -1
View File
@@ -1,6 +1,7 @@
import type { Logger } from '../../utils/logger.js';
import type {
ConversationOutcomesRepository,
ConversationStateRepository,
MessagesRepository,
RedditUsersRepository,
SentMessageRecord,
@@ -21,6 +22,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
export type { InvitationCooldown } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
import type { AccountAuthGate } from '../account-auth-context.js';
import { containsMediaRequest } from '../conversation-boundaries.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
@@ -159,6 +161,7 @@ export class ChatService {
private redditUsersRepo?: RedditUsersRepository,
private accountAuth?: AccountAuthGate,
private outcomesRepo?: ConversationOutcomesRepository,
private conversationStateRepo?: ConversationStateRepository,
) {
this.cache = cache ?? new ChatCache(logger);
if (this.redditUsersRepo) {
@@ -1104,17 +1107,25 @@ export class ChatService {
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
const firstReply = incomingMessages[0];
const latestReply = incomingMessages.at(-1);
const cachedSelfCount = conversationMessages.filter((message) => message.isSelf).length;
const selfCount = cachedSelfCount + (firstSelf ? 0 : 1);
const otherCount = incomingMessages.length;
const wasMissingOutcome = this.outcomesRepo.get(performanceMessage.id) === undefined;
const conversationState = this.conversationStateRepo?.get(chat.roomId);
try {
recordConversationReply(
performanceMessage,
firstReply?.text.slice(0, 200) ?? '',
latestReply?.text.slice(0, 200) ?? '',
{
messageCount: selfCount + otherCount,
reciprocal: selfCount >= 2 && otherCount >= 2,
responses: incomingMessages.map((message) => message.text),
learningEligible: conversationState !== undefined && conversationState.automation.status !== 'active',
receivedImage: hasReceivedImage,
mediaWasRequested: conversationState?.askedForMedia
?? conversationMessages.some((message) => message.isSelf && containsMediaRequest(message.text)),
authoritativeTranscript: true,
},
{
messages: this.messagesRepo,
+137 -36
View File
@@ -19,6 +19,7 @@ import {
remainingAutomationTurns,
type ConversationState,
} from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
export type ConversationAutomationResultKind =
| 'sent'
@@ -44,8 +45,14 @@ export interface ConversationAutomationClaims {
markAttempt?(conversationId: string, incomingKey: string): boolean;
releasePending?(conversationId: string, incomingKey: string): boolean;
markSent(conversationId: string, incomingKey: string): boolean;
markSuperseded?(conversationId: string, incomingKey: string): boolean;
}
export type ConversationBeforeSendLoader = (
conversationId: string,
observed: ActiveChat,
) => Promise<ActiveChat | undefined>;
export interface ReplyPriorityStatus {
processing: boolean;
actionableBacklog: number;
@@ -86,6 +93,7 @@ export class ConversationAutomationService {
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
private readonly now: () => number = Date.now,
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
) {}
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -199,26 +207,21 @@ export class ConversationAutomationService {
);
}
if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null;
const retryPlan = replyPlan(state);
const exhausted = this.advanceStateForReply(
state,
conversationId,
incomingKey,
existingClaim.replyText,
retryPlan.isLegacy,
retryPlan.isHybridMediaAsk,
);
const requestsMedia = containsMediaRequest(existingClaim.replyText);
const isHybridMediaAsk = state.automation.policyMode === 'r4r-media' && requestsMedia;
return this.sendClaimedReply(
conversation,
state,
incomingKey,
existingClaim.replyText,
latest.timestamp,
exhausted,
requestsMedia,
isHybridMediaAsk,
signal,
true,
);
}
if (existingClaim?.status === 'sent') return null;
if (existingClaim?.status === 'sent' || existingClaim?.status === 'superseded') return null;
if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') return null;
@@ -269,6 +272,16 @@ export class ConversationAutomationService {
}
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
if (isLegacy && state.askedForMedia) {
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
'A media request has already been made; further autonomous requests are disabled.',
);
}
if (isHybridMediaAsk) {
state.automation.hybridPhase = 'media-ask';
this.states.save(conversationId, state);
@@ -283,7 +296,9 @@ export class ConversationAutomationService {
: isHybridMediaAsk
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
const safeReply = isLegacy || isHybridMediaAsk
? isSafeHybridMediaAsk(reply)
: isSafeR4RReply(reply);
if (!safeReply) {
return this.finish(
conversation,
@@ -291,7 +306,7 @@ export class ConversationAutomationService {
state,
incomingKey,
'human-required',
'The generated R4R reply failed autonomous safety checks.',
'The generated reply failed autonomous safety checks.',
);
}
if (this.dryRun) {
@@ -301,24 +316,16 @@ export class ConversationAutomationService {
if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null;
// Persist the full intended state before the remote send. If the process
// dies after Matrix accepts the message, both this state and the durable
// event claim make the retry fail closed instead of sending twice.
const exhausted = this.advanceStateForReply(
state,
conversationId,
incomingKey,
reply,
isLegacy,
isHybridMediaAsk,
);
// The durable claim stores the intended body. Send-time validation then
// snapshots the intended state immediately before the Matrix request.
return this.sendClaimedReply(
conversation,
state,
incomingKey,
reply,
latest.timestamp,
exhausted,
requestsMedia,
isHybridMediaAsk,
signal,
);
}
@@ -360,6 +367,7 @@ export class ConversationAutomationService {
const claim = this.claims.getClaim?.(conversation.roomId, incomingKey);
if (claim?.status === 'pending') return true;
if (claim?.status === 'sent') return false;
if (claim?.status === 'superseded') return false;
// On the first scan after startup, retain Matrix unread as the conservative
// bootstrap signal so a deployment cannot revive historical read replies.
// Later scans can recover fresh inbound events even if that flag cleared.
@@ -376,11 +384,11 @@ export class ConversationAutomationService {
conversationId: string,
incomingKey: string,
reply: string,
isLegacy: boolean,
requestsMedia: boolean,
isHybridMediaAsk: boolean,
): boolean {
if (state.automation.lastIncomingKey !== incomingKey) {
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
if (requestsMedia) state.askedForMedia = true;
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
@@ -405,33 +413,126 @@ export class ConversationAutomationService {
incomingKey: string,
reply: string,
incomingAt: number,
exhausted: boolean,
requestsMedia: boolean,
isHybridMediaAsk: boolean,
signal?: AbortSignal,
resumePending = false,
): Promise<ConversationAutomationResult | null> {
this.claims.markAttempt?.(conversation.roomId!, incomingKey);
const sent = await this.rateLimiter.throttle(
() => this.chat.sendChatMessage(conversation.url!, reply),
(success) => success,
const conversationId = conversation.roomId!;
const attempt = await this.rateLimiter.throttle(
async () => {
const latestConversation = await this.loadConversationBeforeSend(conversationId, conversation);
const latest = latestConversation ? latestInboundMessage(latestConversation.messages ?? []) : null;
const latestKey = latest ? messageKey(latest) : null;
const freshState = latestConversation
? mergeConversationState(
this.states.get(conversationId),
buildConversationState((latestConversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
)
: this.states.get(conversationId) ?? state;
const persistedIntentCanResume = resumePending
&& freshState.automation.lastIncomingKey === incomingKey
&& freshState.automation.status === 'human-required';
if (!latestConversation || !latest || latestKey !== incomingKey
|| (freshState.automation.status !== 'active' && !persistedIntentCanResume)) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = latestConversation && latest
? this.applyFreshHandoff(latestConversation, freshState, latest)
: null;
if (!result && latestConversation) this.states.save(conversationId, freshState);
this.logger.info(
`[automation] Superseded queued reply for u/${conversation.username}; a newer conversation state was observed before send`,
);
return { kind: 'superseded' as const, result, state: freshState };
}
if (freshState.boundaries.length > 0 || hasReceivedImage(latestConversation.messages ?? [])) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = this.applyFreshHandoff(latestConversation, freshState, latest);
return { kind: 'superseded' as const, result, state: freshState };
}
if (requestsMedia && freshState.askedForMedia
&& !(resumePending && freshState.automation.lastIncomingKey === incomingKey)) {
this.claims.markSuperseded?.(conversationId, incomingKey);
const result = this.finish(
latestConversation,
conversationId,
freshState,
incomingKey,
'human-required',
'A media request has already been made; further autonomous requests are disabled.',
);
return { kind: 'superseded' as const, result, state: freshState };
}
const exhausted = this.advanceStateForReply(
freshState,
conversationId,
incomingKey,
reply,
requestsMedia,
isHybridMediaAsk,
);
this.claims.markAttempt?.(conversationId, incomingKey);
const sent = await this.chat.sendChatMessage(latestConversation.url!, reply);
return { kind: sent ? 'sent' as const : 'failed' as const, exhausted, state: freshState };
},
(result) => result.kind === 'sent',
signal,
REPLY_SEND_PRIORITY,
);
if (!sent) return null;
if (attempt.kind === 'superseded') return attempt.result;
if (attempt.kind === 'failed') return null;
const sentAt = this.now();
this.claims.markSent(conversation.roomId!, incomingKey);
this.lastReplySentAt = sentAt;
this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt);
this.logger.info(
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} `
`[automation] Sent ${attempt.state.automation.policyMode} turn ${attempt.state.automation.turnsUsed}/${attempt.state.automation.maxAutomatedTurns} `
+ `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`,
);
return {
conversationId: conversation.roomId!,
username: conversation.username,
kind: exhausted ? 'human-required' : 'sent',
remainingTurns: remainingAutomationTurns(state),
kind: attempt.exhausted ? 'human-required' : 'sent',
remainingTurns: remainingAutomationTurns(attempt.state),
};
}
private async loadConversationBeforeSend(conversationId: string, observed: ActiveChat): Promise<ActiveChat | undefined> {
if (this.beforeSendLoader) return this.beforeSendLoader(conversationId, observed);
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
return (await this.chat.getCachedConversations()).find((item) => item.roomId === conversationId);
}
private applyFreshHandoff(
conversation: ActiveChat,
state: ConversationState,
latest: ChatMessage,
): ConversationAutomationResult | null {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'boundary', state.boundaries.join('; '));
}
if (hasReceivedImage(conversation.messages ?? [])) {
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.');
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
state.sellerDetected = true;
return this.finish(
conversation,
conversation.roomId!,
state,
messageKey(latest),
'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
);
}
return null;
}
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
+70
View File
@@ -0,0 +1,70 @@
export interface BoundaryMessage {
text: string;
isSelf: boolean;
}
const MEDIA_NOUN = String.raw`(?:pic|pics|picture|pictures|photo|photos|selfie|selfies|snap|snaps|image|images|video|videos|nude|nudes|content)`;
const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing))`;
const EXPLICIT_MEDIA_REFUSAL_PATTERNS = [
new RegExp(String.raw`\b(?:no|not|never|won't|wouldn't|don't|do not|can't|cannot)\b[^.\n]{0,60}\b${MEDIA_NOUN}\b`, 'i'),
new RegExp(String.raw`\b(?:won't|wouldn't|don't|do not|not going to|never)\b[^.\n]{0,40}\b${MEDIA_VERB}\b`, 'i'),
new RegExp(String.raw`\b(?:zero|no) interest\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN}|anything|something)\b`, 'i'),
new RegExp(String.raw`\b(?:rather not|not comfortable)\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN})\b`, 'i'),
];
const CONTEXTUAL_MEDIA_REFUSAL_PATTERNS = [
/^(?:no|nope|nah|no thanks|not really|rather not|pass)(?:,? thanks)?[.!\s]*$/i,
/^(?:i(?:'m| am)|im|we(?:'re| are)|we're) good(?: thanks)?[.!\s]*$/i,
/\b(?:zero interest|not interested|no interest)\b/i,
/\b(?:not sending|won't send|wouldn't send|don't send|do not send)\b/i,
];
const FULL_STOP_PATTERNS = [
/\bstop\b/i,
/\bleave me alone\b/i,
/\b(?:do not|don't) message\b/i,
/\bback off\b/i,
/^(?:(?:i'm|i am|im)\s+)?not interested[.!\s]*$/i,
/\b(?:creepy|uncomfortable|weirdest (?:reddit )?approach)\b/i,
];
export function containsMediaRequest(text: string): boolean {
return new RegExp(String.raw`\b${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\bgot any (?:more )?${MEDIA_NOUN}\b`, 'i').test(text);
}
export function isFullConversationStop(text: string): boolean {
return FULL_STOP_PATTERNS.some((pattern) => pattern.test(text));
}
export function isMediaRefusal(text: string, mediaWasRequested: boolean): boolean {
const normalized = text.replaceAll('’', "'");
if (EXPLICIT_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
return mediaWasRequested && CONTEXTUAL_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized.trim()));
}
/**
* Detect recipient limits in sequence so short phrases such as "I'm good"
* only become a media boundary when they directly follow a media request.
*/
export function detectConversationBoundaries(messages: readonly BoundaryMessage[]): string[] {
const boundaries: string[] = [];
let lastSelfRequestedMedia = false;
for (const message of messages) {
if (message.isSelf) {
lastSelfRequestedMedia = containsMediaRequest(message.text);
continue;
}
if (isMediaRefusal(message.text, lastSelfRequestedMedia)) {
boundaries.push('does not want to send photos or videos');
}
if (isFullConversationStop(message.text)) {
boundaries.push('does not want further sexual escalation');
}
}
return [...new Set(boundaries)];
}
@@ -29,7 +29,12 @@ export function recordConversationReply(
deps.outcomes.suppressRecipient(message.recipient, 'boundary');
}
if (outcome === 'engaged' && !deps.successful.existsByMessage(message.message)) {
const terminalNegative = outcome === 'boundary' || outcome === 'declined' || outcome === 'upsell';
if (terminalNegative || (context.authoritativeTranscript && (outcome !== 'engaged' || !context.learningEligible))) {
deps.successful.deleteBySentMessageId(message.id);
}
if (outcome === 'engaged' && context.learningEligible && !deps.successful.existsByMessage(message.message)) {
deps.successful.insert({
sentMessageId: message.id,
originalMessage: message.message,
@@ -41,6 +46,8 @@ export function recordConversationReply(
deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
} else if (outcome !== 'engaged') {
deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`);
} else if (!context.learningEligible) {
deps.logger.info(`Response from u/${message.recipient} is not terminal; deferred from learning examples.`);
}
return outcome;
}
+22 -4
View File
@@ -1,3 +1,5 @@
import { isFullConversationStop, isMediaRefusal } from './conversation-boundaries.js';
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
@@ -6,6 +8,16 @@ export interface OutcomeContext {
messageCount?: number;
/** True when both participants have sent at least two messages. */
reciprocal?: boolean;
/** All recipient messages in the attributed conversation, oldest first. */
responses?: readonly string[];
/** True only after automation has reached a terminal/human-owned state. */
learningEligible?: boolean;
/** A received image is an explicit positive outcome once the flow is complete. */
receivedImage?: boolean;
/** Whether the conversation had already asked the recipient for media. */
mediaWasRequested?: boolean;
/** True when responses contains the complete attributed recipient transcript. */
authoritativeTranscript?: boolean;
}
/**
@@ -16,10 +28,16 @@ export function classifyConversationOutcome(
reply: string,
context: OutcomeContext = {},
): ConversationOutcome {
const text = reply.toLowerCase();
const responses = context.responses?.length ? context.responses : [reply];
const text = responses.join('\n').toLowerCase();
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary';
if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined';
if (context.reciprocal && (context.messageCount ?? 0) >= 4 && text.trim().length > 0) return 'engaged';
if (responses.some(isFullConversationStop)) return 'boundary';
if (responses.some((response) => isMediaRefusal(response, context.mediaWasRequested === true))
|| /\b(no thanks|not interested|nope|nah|rather not|zero interest)\b/.test(text)) return 'declined';
const explicitPositive = /\b(yes|yeah|yep|sure|absolutely|definitely|tell me more|sounds (?:good|fun)|i'd love to|would love to)\b/i.test(text);
if (context.learningEligible
&& (context.receivedImage || (context.reciprocal && (context.messageCount ?? 0) >= 4 && explicitPositive))) {
return 'engaged';
}
return 'unknown';
}
+6 -17
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { isSafeOutboundMessage } from './outbound-message-safety.js';
import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js';
export interface ConversationMessage {
sender: string;
@@ -38,7 +39,7 @@ export interface ConversationState {
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 2;
export const CONVERSATION_POLICY_VERSION = 3;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
@@ -103,11 +104,6 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
.filter((message) => !message.isSelf)
.map((message) => message.text)
.join('\n');
const ownText = messages
.filter((message) => message.isSelf)
.map((message) => message.text)
.join('\n');
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
const persona: ConversationPersona = {
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
@@ -115,20 +111,14 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
};
const boundaries: string[] = [];
if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) {
boundaries.push('does not want to send photos or videos');
}
if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) {
boundaries.push('does not want further sexual escalation');
}
const boundaries = detectConversationBoundaries(messages);
const topics = extractTopics(theirText);
return {
persona,
boundaries,
sellerDetected: false,
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)),
topics,
automation: createConversationAutomationState(),
};
@@ -214,14 +204,13 @@ export function isSafeConversationReply(message: string): boolean {
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
export function isSafeR4RReply(message: string): boolean {
if (!isSafeConversationReply(message)) return false;
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\b/i.test(message)
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
return !containsMediaRequest(message);
}
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
export function isSafeHybridMediaAsk(message: string): boolean {
if (!isSafeConversationReply(message)) return false;
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false;
if (!containsMediaRequest(message)) return false;
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
}
@@ -181,7 +181,7 @@ async function buildRuntime(
const chatService = new ChatService(
matrixClient, logger, successfulMessagesRepo, mediaService,
new MatrixTokenRefresher(), usernameResolver, chatCache,
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
);
await chatService.loadCache();
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);