Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

+48
View File
@@ -20,6 +20,7 @@ import {
type ConversationState,
} from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
export type ConversationAutomationResultKind =
| 'sent'
@@ -94,6 +95,7 @@ export class ConversationAutomationService {
private readonly now: () => number = Date.now,
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
private readonly mediaExchange?: MediaLibraryExchangeService,
) {}
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -260,6 +262,52 @@ export class ConversationAutomationService {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
}
}
if (this.mediaExchange?.detect(conversationId, latest.text)) {
const exchangeAttempt = await this.rateLimiter.throttle(
async () => {
const refreshed = await this.loadConversationBeforeSend(conversationId, conversation);
const refreshedLatest = refreshed ? latestInboundMessage(refreshed.messages ?? []) : null;
if (!refreshed || !refreshedLatest) return { kind: 'superseded' as const };
const refreshedState = mergeConversationState(
this.states.get(conversationId),
buildConversationState((refreshed.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
);
if (refreshedState.boundaries.length > 0 || hasReceivedImage(refreshed.messages ?? [])) {
return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
}
const refreshedSellerSignal = findSellerSignal(
refreshedLatest.text,
[...this.sellerKeywords],
[...this.sellerDomains],
);
if (refreshedSellerSignal) {
return { kind: 'human-required' as const, reason: 'Potential upsell appeared before the image could be sent.' };
}
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
return this.mediaExchange!.handle(conversationId, incomingKey, refreshedLatest.text);
},
(result) => result.kind === 'sent' || (result.kind === 'human-required' && result.remoteSent === true),
signal,
REPLY_SEND_PRIORITY,
);
const exchange = exchangeAttempt;
if (exchange.kind === 'sent') {
state.askedForMedia = true;
state.automation.lastIncomingKey = incomingKey;
this.states.save(conversationId, state);
return {
conversationId,
username: conversation.username,
kind: 'sent',
remainingTurns: remainingAutomationTurns(state),
};
}
if (exchange.kind === 'human-required') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', exchange.reason);
}
if (exchange.kind === 'retryable' || exchange.kind === 'dry-run') return null;
if (exchange.kind === 'superseded') return null;
}
if (remainingAutomationTurns(state) === 0) {
return this.finish(
conversation,