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

+53 -3
View File
@@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
import type { ConversationState } from '../src/services/conversation-state.js';
function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
function harness(
sellerKeywords: string[] = [],
sellerDomains: string[] = [],
openerSentAt: number | null = 0,
) {
let saved: ConversationState | undefined;
const chat = {
refreshTopChats: vi.fn().mockResolvedValue(undefined),
@@ -14,15 +18,24 @@ function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
get: vi.fn(() => saved),
save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }),
};
const openers = {
getByChatRoomId: vi.fn(() => openerSentAt === null
? undefined
: { sentAt: openerSentAt }),
};
const claims = {
tryClaim: vi.fn(() => true),
markSent: vi.fn(() => true),
};
const rateLimiter = {
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
};
const logger = { info: vi.fn() };
const service = new ConversationAutomationService(
chat as never, ai as never, states as never, rateLimiter as never, logger as never,
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
false, sellerKeywords, sellerDomains,
);
return { service, chat, ai, states, getSaved: () => saved };
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
}
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
@@ -98,4 +111,41 @@ describe('conversation automation', () => {
expect(chat.sendChatMessage).toHaveBeenCalledTimes(1);
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
});
it('does not reply when the room has no recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], null);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('does not reply to an inbound event older than the recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], 4);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('claims an inbound event durably before sending and marks it sent afterwards', async () => {
const { service, chat, claims } = harness();
await service.processConversation(incoming(1));
expect(claims.tryClaim).toHaveBeenCalledWith('room-alice', 'in-1');
expect(claims.tryClaim.mock.invocationCallOrder[0])
.toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
});
it('does not send when another worker already claimed the inbound event', async () => {
const { service, chat, claims } = harness();
claims.tryClaim.mockReturnValue(false);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
});
+33 -1
View File
@@ -107,7 +107,7 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(14);
expect(tables.length).toBe(15);
});
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
@@ -133,6 +133,38 @@ describe('Database Client', () => {
});
});
it('backfills global recipient suppression from sent messages and known conversations', () => {
runMigrations(db);
db.prepare(`
INSERT INTO reddit_conversations (
account_id, conversation_id, username, room_name, payload_json, updated_at
) VALUES (?, 'old-room', 'Old_Contact', 'Old Contact', '{}', 20)
`).run('__legacy_unassigned__');
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'old-post', 'test', 'sent-contact', 'title', 10)
`).run('__legacy_unassigned__');
db.prepare(`
INSERT INTO sent_messages (
account_id, post_id, recipient, subject, message, sent_at
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
`).run('__legacy_unassigned__');
const latestVersion = db.pragma('user_version', { simple: true }) as number;
db.exec(`
DELETE FROM recipient_suppressions;
DROP TABLE conversation_automation_claims;
`);
db.pragma(`user_version = ${latestVersion - 1}`);
runMigrations(db);
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
.toEqual([
{ recipient: 'Old_Contact', reason: 'historical-conversation' },
{ recipient: 'Sent_Contact', reason: 'sent-message-history' },
]);
});
it('processed_posts has correct schema', () => {
runMigrations(db);
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
@@ -5,6 +5,7 @@ import { runMigrations } from '../src/database/client.js';
import {
ActivityLogRepository,
ConversationOutcomesRepository,
ConversationAutomationClaimRepository,
ConversationStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
@@ -101,6 +102,45 @@ describe('account-bound domain repositories', () => {
expect(statesB.get('same-room')?.boundaries).toContain('does not want further sexual escalation');
});
it('atomically claims each inbound automation event once per account', () => {
const claimsA = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
const claimsB = new ConversationAutomationClaimRepository(db, ACCOUNT_B);
expect(claimsA.tryClaim('same-room', 'same-event', 10)).toBe(true);
expect(claimsA.tryClaim('same-room', 'same-event', 11)).toBe(false);
expect(claimsB.tryClaim('same-room', 'same-event', 12)).toBe(true);
expect(claimsA.markSent('same-room', 'same-event', 13)).toBe(true);
expect(claimsA.markSent('same-room', 'same-event', 14)).toBe(false);
});
it('claims recipients globally and blocks usernames with existing chat history', () => {
db.prepare(`
INSERT INTO reddit_conversations (
account_id, conversation_id, username, room_name, payload_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
ACCOUNT_A,
'historical-room',
'Historical_User',
'Historical User',
JSON.stringify({
messages: [{ sender: 'Me', text: 'prior opener', timestamp: 1, isSelf: true }],
}),
2,
);
const history = new RecipientHistoryRepository(db);
expect(history.hasSentTo('historical_user')).toBe(true);
expect(history.tryClaim('HISTORICAL_USER', 3)).toBe(false);
expect(history.tryClaim('new_user', 4)).toBe(true);
expect(history.tryClaim('NEW_USER', 5)).toBe(false);
history.releaseClaim('new_user');
expect(history.tryClaim('new_user', 6)).toBe(true);
history.commitClaim('new_user');
history.releaseClaim('new_user');
expect(history.hasSentTo('NEW_USER')).toBe(true);
});
it('isolates outbox claims, successful examples, activity and outcomes', () => {
const postsA = new PostsRepository(db, ACCOUNT_A);
const postsB = new PostsRepository(db, ACCOUNT_B);