fix: prevent duplicate outreach and follow-ups

This commit is contained in:
jjbrunton committed 2026-08-09 07:48:39 +01:00
1 parent acc610c2bf
commit 5c01d7d9aa
9 files changed
+274 -14

No files matched your search

+31 -7
View File
@@ -1,5 +1,5 @@
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ConversationStateRepository } from '../database/repositories.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';
@@ -28,6 +28,15 @@ export interface ConversationAutomationResult {
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
@@ -38,6 +47,8 @@ export class ConversationAutomationService {
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,
@@ -66,6 +77,13 @@ export class ConversationAutomationService {
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),
@@ -106,13 +124,11 @@ export class ConversationAutomationService {
return null;
}
const sent = await this.rateLimiter.throttle(
() => this.chat.sendChatMessage(conversation.url!, reply),
(success) => success,
signal,
);
if (!sent) 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;
@@ -123,6 +139,14 @@ export class ConversationAutomationService {
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}`,
);