Files
RedditBot/src/services/conversation-automation.ts
T

189 lines
7.2 KiB
TypeScript

import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ConversationStateRepository, SentMessageRecord } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import { findSellerSignal } from '../reddit/api.js';
import type { ActiveChat, ChatMessage } from './chat/types.js';
import type { ChatService } from './chat/service.js';
import {
buildConversationState,
chooseConversationIntent,
conversationMessageFingerprint,
MAX_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
remainingAutomationTurns,
type ConversationState,
} from './conversation-state.js';
export type ConversationAutomationResultKind =
| 'sent'
| 'human-required'
| 'boundary'
| 'image-received';
export interface ConversationAutomationResult {
conversationId: string;
username: string;
kind: ConversationAutomationResultKind;
remainingTurns: number;
}
export interface ConversationOpenerHistory {
getByChatRoomId(chatRoomId: string): SentMessageRecord | undefined;
}
export interface ConversationAutomationClaims {
tryClaim(conversationId: string, incomingKey: string): boolean;
markSent(conversationId: string, incomingKey: string): boolean;
}
/**
* Sends at most one reply for each newly received user message. Durable state
* makes retries and restarts idempotent, and every conversation stops after
* three automated turns or immediately when consent is withdrawn.
*/
export class ConversationAutomationService {
constructor(
private readonly chat: ChatService,
private readonly ai: OpenRouterClient,
private readonly states: ConversationStateRepository,
private readonly openers: ConversationOpenerHistory,
private readonly claims: ConversationAutomationClaims,
private readonly rateLimiter: RateLimiter,
private readonly logger: Logger,
private readonly dryRun = false,
private readonly sellerKeywords: readonly string[] = [],
private readonly sellerDomains: readonly string[] = [],
) {}
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
await this.chat.refreshTopChats(5);
const conversations = await this.chat.getCachedConversations();
const results: ConversationAutomationResult[] = [];
for (const conversation of conversations) {
const result = await this.processConversation(conversation, signal);
if (result) results.push(result);
}
return results;
}
async processConversation(
conversation: ActiveChat,
signal?: AbortSignal,
): Promise<ConversationAutomationResult | null> {
if (!conversation.unread || conversation.isFromUs || !conversation.roomId || !conversation.url) return null;
const messages = conversation.messages ?? [];
const latest = messages.at(-1);
if (!latest || latest.isSelf) return null;
const conversationId = conversation.roomId;
const opener = this.openers.getByChatRoomId(conversationId);
if (!opener || latest.timestamp <= opener.sentAt) {
this.logger.debug?.(
`[automation] Ignoring unverified inbound event for u/${conversation.username}: no confirmed post-opener response in this room`,
);
return null;
}
const mapped = messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const state = mergeConversationState(
this.states.get(conversationId),
buildConversationState(mapped),
);
const incomingKey = messageKey(latest);
if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') return null;
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
);
}
if (hasReceivedImage(messages)) {
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
}
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (remainingAutomationTurns(state) === 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', 'Three automated turns have been used.');
}
const intent = chooseConversationIntent(mapped, state, 'media-ask');
if (intent.kind !== 'media-ask') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
}
const reply = await this.ai.generateMediaAsk(mapped, conversation.username, state, intent);
if (this.dryRun) {
this.logger.info(`[automation] Dry run prepared turn ${state.automation.turnsUsed + 1} for u/${conversation.username}`);
return null;
}
if (!this.claims.tryClaim(conversationId, incomingKey)) 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.
state.askedForMedia = true;
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
if (exhausted) {
state.automation.status = 'human-required';
state.automation.handoffReason = 'Three automated turns have been used.';
}
this.states.save(conversationId, state);
const sent = await this.rateLimiter.throttle(
() => this.chat.sendChatMessage(conversation.url!, reply),
(success) => success,
signal,
);
if (!sent) return null;
this.claims.markSent(conversationId, incomingKey);
this.logger.info(
`[automation] Sent turn ${state.automation.turnsUsed}/${MAX_AUTOMATED_CONVERSATION_TURNS} to u/${conversation.username}`,
);
return {
conversationId,
username: conversation.username,
kind: exhausted ? 'human-required' : 'sent',
remainingTurns: remainingAutomationTurns(state),
};
}
private finish(
conversation: ActiveChat,
conversationId: string,
state: ConversationState,
incomingKey: string,
status: 'image-received' | 'human-required' | 'boundary',
reason: string,
): ConversationAutomationResult {
state.automation.status = status;
state.automation.lastIncomingKey = incomingKey;
state.automation.handoffReason = reason;
this.states.save(conversationId, state);
return {
conversationId,
username: conversation.username,
kind: status,
remainingTurns: remainingAutomationTurns(state),
};
}
}
function hasReceivedImage(messages: ChatMessage[]): boolean {
return messages.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0);
}
function messageKey(message: ChatMessage): string {
return message.eventId ?? `${message.timestamp}:${message.sender}:${message.text}`;
}