fix: prevent duplicate outreach and follow-ups
This commit is contained in:
1 parent
acc610c2bf
commit
5c01d7d9aa
9 files changed
+274
-14
No files matched your search
@@ -217,6 +217,40 @@ export function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
},
|
||||
() => db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_reddit_conversations_global_username
|
||||
ON reddit_conversations(username COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_sent_messages_global_recipient
|
||||
ON sent_messages(recipient COLLATE NOCASE);
|
||||
|
||||
INSERT OR IGNORE INTO recipient_suppressions (recipient, reason, created_at)
|
||||
SELECT recipient, 'sent-message-history', MIN(sent_at)
|
||||
FROM sent_messages
|
||||
WHERE trim(recipient) <> ''
|
||||
GROUP BY recipient COLLATE NOCASE;
|
||||
|
||||
INSERT OR IGNORE INTO recipient_suppressions (recipient, reason, created_at)
|
||||
SELECT rc.username, 'historical-conversation', MIN(rc.updated_at)
|
||||
FROM reddit_conversations rc
|
||||
WHERE trim(rc.username) <> ''
|
||||
AND lower(trim(rc.username)) NOT IN ('unknown', '[deleted]')
|
||||
AND lower(trim(rc.username)) NOT LIKE 't2\\_%' ESCAPE '\\'
|
||||
AND trim(rc.username) NOT LIKE '@%'
|
||||
GROUP BY rc.username COLLATE NOCASE;
|
||||
|
||||
CREATE TABLE conversation_automation_claims (
|
||||
account_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
incoming_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'sent')),
|
||||
claimed_at INTEGER NOT NULL,
|
||||
sent_at INTEGER,
|
||||
PRIMARY KEY (account_id, conversation_id, incoming_key),
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_conversation_automation_claims_status
|
||||
ON conversation_automation_claims(account_id, status, claimed_at);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -120,9 +120,12 @@ export interface DeliveryOutboxRecord {
|
||||
sentMessageId: number | null;
|
||||
}
|
||||
|
||||
/** Read-only recipient history shared by every Reddit account runtime. */
|
||||
/** Global recipient history and durable pre-send claims shared by all account runtimes. */
|
||||
export class RecipientHistoryRepository {
|
||||
private readonly hasSentToStmt: Database.Statement;
|
||||
private readonly tryClaimStmt: Database.Statement;
|
||||
private readonly commitClaimStmt: Database.Statement;
|
||||
private readonly releaseClaimStmt: Database.Statement;
|
||||
|
||||
constructor(db: Database.Database) {
|
||||
this.hasSentToStmt = db.prepare(`
|
||||
@@ -130,13 +133,49 @@ export class RecipientHistoryRepository {
|
||||
SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE
|
||||
UNION ALL
|
||||
SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE
|
||||
UNION ALL
|
||||
SELECT username AS recipient
|
||||
FROM reddit_conversations
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
)
|
||||
LIMIT 1
|
||||
`);
|
||||
this.tryClaimStmt = db.prepare(`
|
||||
INSERT INTO recipient_suppressions (recipient, reason, created_at)
|
||||
SELECT ?, 'pending-send', ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sent_messages WHERE recipient = ? COLLATE NOCASE
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM reddit_conversations
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
)
|
||||
ON CONFLICT(recipient) DO NOTHING
|
||||
`);
|
||||
this.commitClaimStmt = db.prepare(`
|
||||
UPDATE recipient_suppressions SET reason = 'sent'
|
||||
WHERE recipient = ? COLLATE NOCASE AND reason = 'pending-send'
|
||||
`);
|
||||
this.releaseClaimStmt = db.prepare(`
|
||||
DELETE FROM recipient_suppressions
|
||||
WHERE recipient = ? COLLATE NOCASE AND reason = 'pending-send'
|
||||
`);
|
||||
}
|
||||
|
||||
hasSentTo(recipient: string): boolean {
|
||||
return this.hasSentToStmt.get(recipient, recipient) !== undefined;
|
||||
return this.hasSentToStmt.get(recipient, recipient, recipient) !== undefined;
|
||||
}
|
||||
|
||||
tryClaim(recipient: string, now: number = Date.now()): boolean {
|
||||
return this.tryClaimStmt.run(recipient, now, recipient, recipient).changes === 1;
|
||||
}
|
||||
|
||||
commitClaim(recipient: string): void {
|
||||
this.commitClaimStmt.run(recipient);
|
||||
}
|
||||
|
||||
releaseClaim(recipient: string): void {
|
||||
this.releaseClaimStmt.run(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,6 +1061,31 @@ export class ConversationStateRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable idempotency claims for AI replies to individual inbound events. */
|
||||
export class ConversationAutomationClaimRepository {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
|
||||
) {}
|
||||
|
||||
tryClaim(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
INSERT INTO conversation_automation_claims (
|
||||
account_id, conversation_id, incoming_key, status, claimed_at
|
||||
) VALUES (?, ?, ?, 'pending', ?)
|
||||
ON CONFLICT(account_id, conversation_id, incoming_key) DO NOTHING
|
||||
`).run(this.accountId, conversationId, incomingKey, now).changes === 1;
|
||||
}
|
||||
|
||||
markSent(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE conversation_automation_claims
|
||||
SET status = 'sent', sent_at = ?
|
||||
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
|
||||
`).run(now, this.accountId, conversationId, incomingKey).changes === 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome labels are recorded independently from a raw inbox response. */
|
||||
export class ConversationOutcomesRepository {
|
||||
constructor(
|
||||
|
||||
@@ -349,6 +349,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
recipientDeduplicator.commit(post.author);
|
||||
|
||||
const currentCount = currentHourlySendCount(rateLimiter);
|
||||
logger.info(
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export interface RecipientHistory {
|
||||
hasSentTo(recipient: string): boolean;
|
||||
tryClaim?(recipient: string): boolean;
|
||||
commitClaim?(recipient: string): void;
|
||||
releaseClaim?(recipient: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,13 +27,20 @@ export class RecipientDeduplicator {
|
||||
if (!key || this.reservations.has(key) || this.history.hasSentTo(recipient)) {
|
||||
return false;
|
||||
}
|
||||
if (this.history.tryClaim && !this.history.tryClaim(recipient)) return false;
|
||||
this.reservations.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
commit(recipient: string): void {
|
||||
this.history.commitClaim?.(recipient);
|
||||
}
|
||||
|
||||
release(recipient: string): void {
|
||||
const key = normalizeRecipient(recipient);
|
||||
if (key) this.reservations.delete(key);
|
||||
if (!key) return;
|
||||
this.reservations.delete(key);
|
||||
this.history.releaseClaim?.(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BrowserClient } from '../browser/client.js';
|
||||
import {
|
||||
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
|
||||
DeliveryOutboxRepository, ConversationStateRepository,
|
||||
ConversationAutomationClaimRepository,
|
||||
ConversationOutcomesRepository, RedditUsersRepository,
|
||||
ActivityLogRepository,
|
||||
RecipientHistoryRepository,
|
||||
@@ -108,6 +109,7 @@ async function buildRuntime(
|
||||
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
|
||||
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
|
||||
const conversationStateRepo = new ConversationStateRepository(db, session.accountId);
|
||||
const conversationAutomationClaimRepo = new ConversationAutomationClaimRepository(db, session.accountId);
|
||||
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
|
||||
const redditUsersRepo = new RedditUsersRepository(db);
|
||||
const accountContentRepo = new AccountContentRepository(db);
|
||||
@@ -158,6 +160,8 @@ async function buildRuntime(
|
||||
chatService,
|
||||
aiClient,
|
||||
conversationStateRepo,
|
||||
messagesRepo,
|
||||
conversationAutomationClaimRepo,
|
||||
rateLimiter,
|
||||
logger,
|
||||
accountConfig.browser.dryRun,
|
||||
@@ -331,6 +335,7 @@ class AccountRuntimeLoop {
|
||||
modelId: processed.modelId, selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
this.recipientDeduplicator.commit(post.author);
|
||||
this.resources.activityLogRepo.insert({
|
||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
recipient: post.author, imageUrl: imageUrl ?? undefined, postTitle: post.title, postUrl: redditApi.getPostUrl(post),
|
||||
|
||||
Reference in new issue
Block a user