feat: automate bounded image-sharing conversations
This commit is contained in:
1 parent
dcf57273e2
commit
3beb8aada2
15 files changed
+477
-19
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
||||
import type { ConversationStateRepository } from '../database/repositories.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { RateLimiter } from '../utils/rate-limiter.js';
|
||||
import type { ActiveChat, ChatMessage } from './chat/types.js';
|
||||
import type { ChatService } from './chat/service.js';
|
||||
import {
|
||||
buildConversationState,
|
||||
chooseConversationIntent,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 rateLimiter: RateLimiter,
|
||||
private readonly logger: Logger,
|
||||
private readonly dryRun = false,
|
||||
) {}
|
||||
|
||||
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 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const sent = await this.rateLimiter.throttle(
|
||||
() => this.chat.sendChatMessage(conversation.url!, reply),
|
||||
(success) => success,
|
||||
signal,
|
||||
);
|
||||
if (!sent) return null;
|
||||
|
||||
state.askedForMedia = true;
|
||||
state.automation.turnsUsed += 1;
|
||||
state.automation.lastIncomingKey = incomingKey;
|
||||
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);
|
||||
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}`;
|
||||
}
|
||||
Reference in new issue
Block a user